Skip to content

IAM Access Policies

An access policy grants or denies a machine principal — an API key or a Workload Identity binding — permission to call the Frostmoln API. It is the Frostmoln equivalent of an AWS IAM policy, GCP IAM, or Azure RBAC, but written in Frostmoln's own vocabulary.

A policy lets you express least privilege that a flat scope list cannot: a single operation instead of all of compute:write, one resource, a source-IP range, a time window — and explicit deny. For example: a CI key that may create instances but never delete them; a Terraform key that only works from your office network; an SDK key that may only touch resources tagged env=staging.

Pilot — access is granted per tenant

Access-policy authoring is rolled out as a pilot. It is available only to tenants for which the feature has been enabled. If you don't see IAM in the portal or fm iam returns not available, it isn't enabled for your tenant yet — contact support to request access. Your existing API keys and their scopes keep working unchanged in the meantime.

Who authors policies

Access policies govern machine principals, but they are authored by a human signed in with OIDC (the portal, the fm CLI after fm login, or Terraform with an OIDC-backed provider). An API key or workload-identity token cannot create, change, or attach a policy — the management plane can never be locked out by a policy it is subject to. This is deliberate: even a deny * policy on your own key is recoverable, because you fix it as a human.

How evaluation works

Evaluation is default-deny:

  1. If any deny rule matches, the request is denied — an explicit deny always wins, regardless of rule order or which policy it came from.
  2. Otherwise, if any allow rule matches, the request is allowed.
  3. Otherwise the request is denied (implicit deny).

A request matches a rule when its operation matches one of the rule's operations, its target FRN matches one of the rule's targets, and all of the rule's constraints hold.

The policy document

A policy is a JSON document: a schemaVersion and a list of rules.

json
{
  "schemaVersion": "1",
  "rules": [
    {
      "name": "compute-read-only",
      "access": "allow",
      "operations": ["compute:instances:read", "compute:instances:list"],
      "targets": ["frn:compute:*:*:instances/*"]
    }
  ]
}

Each rule has these fields:

FieldRequiredValue
namenoA human label for the rule. Free text; has no effect on evaluation.
accessyesallow or deny.
operationsyesNon-empty list of operation patterns (see Operations).
targetsyesNon-empty list of FRN patterns (see Targets — FRN).
constraintsnoExtra conditions that must hold (see Constraints).

schemaVersion is the string "1". The schema is append-only and versioned like the rest of the platform contract — new capabilities are added, never renamed or removed, so a policy you write today keeps working.

Operations

An operation names one action in the format service:resource:action, for example compute:instances:create or storage:volumes:delete. Each segment accepts the * wildcard, and wildcards collapse at each level:

  • compute:instances:create — one exact operation
  • compute:instances:* — every action on compute instances
  • compute:* — every compute operation
  • *:*:delete — every delete, across every service

The set of concrete operations is a server-owned, append-only catalog — do not hardcode it, because it grows over time. Fetch the live catalog:

bash
fm iam catalog                 # all operations
fm iam catalog --filter compute   # only those containing "compute"

or over HTTP:

bash
curl -H "Authorization: Bearer $TOKEN" \
  https://api.frostmoln.cloud/api/v1/iam/catalog
# → { "operations": ["billing:invoices:create", "compute:instances:create", ... ] }

The portal's policy builder presents the same catalog as a picker.

Targets — FRN

Targets are matched against a Frostmoln Resource Name (FRN) — the platform's resource-naming scheme, analogous to an AWS ARN:

frn:<service>:<region>:<tenant>:<type>/<id>

In a rule's targets you write FRN patterns, using * to wildcard any segment:

  • * — matches every resource (use sparingly)
  • frn:compute:*:*:instances/* — any compute instance, this tenant's
  • frn:storage:*:*:volumes/* — any volume, this tenant's
  • frn:compute:*:*:instances/i-0a1b2c3d — one specific instance

The <region> segment must be *

Region-scoped policies are not supported yet: requests do not carry a resolved region, so a target that names one cannot be matched reliably. Always write * there — a target with anything else is rejected when you save the policy. Every other segment works as described above.

The <tenant> segment is your tenant

Leave <tenant> as *: because every request carries your own tenant, * already resolves to your tenant and nothing else. You may also write your own tenant id explicitly, but a target naming another tenant is rejected when you save — it could never match, so the rule would silently have no effect.

Constraints

Constraints add conditions to a rule. A rule's constraints is a map keyed by operator, then by constraint key:

json
{
  "name": "read-staging-from-office",
  "access": "allow",
  "operations": ["compute:instances:read"],
  "targets": ["frn:compute:*:*:instances/*"],
  "constraints": {
    "ipInRange": { "frn:sourceIp": ["203.0.113.0/24"] },
    "equals": { "frn:resourceTag/env": "staging" }
  }
}

Constraint keys

KeyMeaning
frn:regionNot usable yet. Requests do not carry a resolved region, so this key never resolves: an allow using it never grants, and a deny using it fires everywhere. Leave it out.
frn:sourceIpThe caller's source IP.
frn:tenantThe tenant id.
frn:principalTypeThe principal kind: api_key or workload_identity.
frn:currentTimeThe request time (for before/after).
frn:requestTag/<k>The value of request tag <k>.
frn:resourceTag/<k>The value of tag <k> on the target resource.

Operators and value shapes

OperatorValue shapeNotes
equalsa single stringExact match.
notEqualsa single stringNegated exact match.
likea single stringGlob match (* wildcard).
ipInRangea list of CIDR stringsUsed with frn:sourceIp.
beforea single RFC3339 timestampUsed with frn:currentTime.
aftera single RFC3339 timestampUsed with frn:currentTime.

ipInRange is the only operator that takes a list; every other operator takes a single scalar string. An unknown key or operator, or a value a constraint can't resolve at request time, fails closed — the constraint does not hold (so an allow won't grant), and a deny still fires.

⚠️ A constraint on a deny narrows it

This is the single most important thing to get right. Constraints are applied the same way to allow and deny rules: a rule only matches when its constraints hold. So a constraint on a deny rule makes the deny fire only when the constraint is definitively true — leaving the operation permitted whenever the constraint is definitively false. (An unresolved or unknown constraint still fires the deny — fail-closed.)

  • To forbid an operation unconditionally, write a deny rule with no constraint.
  • To restrict a grant (to a source IP, a time window, a resource tag), put the constraint on the allow rule.
json
{
  "schemaVersion": "1",
  "rules": [
    {
      "name": "create-read-from-office",
      "access": "allow",
      "operations": ["compute:instances:create", "compute:instances:read"],
      "targets": ["frn:compute:*:*:instances/*"],
      "constraints": { "ipInRange": { "frn:sourceIp": ["203.0.113.0/24"] } }
    },
    {
      "name": "never-delete",
      "access": "deny",
      "operations": ["compute:instances:delete"],
      "targets": ["*"]
    }
  ]
}

The allow is network-restricted by its constraint; the deny is unconstrained, so delete is forbidden always. A constraint on that deny would have allowed deletes whenever the constraint was false.

Authoring a policy

Author in the portal, with the fm CLI, or with Terraform. A policy is written once and then attached to one or more principals.

Portal

Open IAM → Access Policies, choose New policy, and use the guided builder — pick operations from the catalog, add targets and constraints, and use the built-in tester to check a request before saving. Then open a principal (API key or workload identity) and attach the policy.

fm CLI

bash
# Create from a file, stdin, or inline JSON
fm iam policy create --name ci-compute-operator --document @policy.json
cat policy.json | fm iam policy create --name ci-compute-operator --document -

# List / inspect / update / delete
fm iam policy list
fm iam policy get <policy-id>
fm iam policy update <policy-id> --document @policy.json
fm iam policy delete <policy-id>

# Attach to a principal (api_key | workload_identity | group)
fm iam policy attach <policy-id> --type api_key --id <key-id>
fm iam policy detach <policy-id> --type api_key --id <key-id>

Terraform

Compose the document with the frostmoln_iam_policy_document data source (native vocabulary — rule / access / operations / targets / constraint), then create the policy and attach it:

hcl
data "frostmoln_iam_policy_document" "ci" {
  # Restrict the grant with a constraint on the ALLOW rule.
  rule {
    name       = "create-read-from-office"
    access     = "allow"
    operations = ["compute:instances:create", "compute:instances:read"]
    targets    = ["frn:compute:*:*:instances/*"]

    constraint {
      operator = "ipInRange"
      key      = "frn:sourceIp"
      values   = ["203.0.113.0/24"]
    }
  }

  # Forbid delete unconditionally — a deny with NO constraint.
  rule {
    name       = "never-delete"
    access     = "deny"
    operations = ["compute:instances:delete"]
    targets    = ["*"]
  }
}

resource "frostmoln_iam_policy" "ci" {
  name        = "ci-compute-operator"
  description = "CI: create/read compute from the office network, never delete"
  document    = data.frostmoln_iam_policy_document.ci.json
}

resource "frostmoln_iam_policy_attachment" "ci_key" {
  policy_id     = frostmoln_iam_policy.ci.id
  attachee_type = "api_key" # or "workload_identity" / "group"
  attachee_id   = frostmoln_api_key.ci.id
}

ipInRange takes a list of CIDRs; every other operator's values is a single element. The data source rejects a multi-value list on any other operator.

Groups

To attach the same policy to several principals, create a group, add principals to it, and attach the policy to the group:

bash
fm iam group create --name ci-keys
fm iam group member add <group-id> --type api_key --id <key-id>
fm iam policy attach <policy-id> --type group --id <group-id>

A member is an api_key or a workload_identity; a policy attaches to an api_key, a workload_identity, or a group.

Test before you save

A misauthored policy has real teeth in production, so test it first. The tester (portal builder, or fm iam simulate) runs a candidate document against a hypothetical request and returns allow or deny — it never persists or enforces anything:

bash
fm iam simulate --document @policy.json \
  --operation compute:instances:delete --target '*'
# → deny

fm iam simulate --document @policy.json \
  --operation compute:instances:read --target 'frn:compute:*:*:instances/*' \
  --source-ip 203.0.113.10
# → allow

Constraint context flags: --source-ip, --tenant, --principal-type, --request-tag key=value, --resource-tag key=value. An omitted constraint field is treated as unresolved / fail-closed, exactly as in production. (--region exists too, but frn:region is not usable yet — see Constraint keys.)

Migrating from scopes

Your existing API-key scopes (compute:read, storage:write, …) still work — every legacy scope is evaluated as an equivalent synthesized allow policy, so there is zero forced migration. compute:read behaves like an allow over the compute read/list operations on frn:compute:*.

You migrate a key to a real least-privilege policy when you want something scopes can't express. For example, replacing a broad compute:write scope:

compute:write grants create and update and delete on every compute resource, from anywhere. A least-privilege replacement might be:

json
{
  "schemaVersion": "1",
  "rules": [
    {
      "name": "create-and-update-from-office",
      "access": "allow",
      "operations": [
        "compute:instances:create",
        "compute:instances:update",
        "compute:instances:read",
        "compute:instances:list"
      ],
      "targets": ["frn:compute:*:*:instances/*"],
      "constraints": { "ipInRange": { "frn:sourceIp": ["203.0.113.0/24"] } }
    },
    {
      "name": "never-delete",
      "access": "deny",
      "operations": ["compute:instances:delete"],
      "targets": ["*"]
    }
  ]
}

Steps:

  1. Author the policy (above) and test it with fm iam simulate against the operations your workload actually performs.
  2. Attach it to the key.
  3. Narrow the scopes on the key (the policy is additive; both the synthesized scope-policy and your attached policy are evaluated, and any explicit deny wins). Remove the broad compute:write scope once the attached policy covers what the key needs.
  4. Re-run fm iam simulate (or watch the key in the portal) to confirm the grants and denials are what you expect.

Because an explicit deny overrides any allow, the never-delete rule above holds even while the legacy scope is still present — a safe way to tighten a key before you finish removing its scopes.