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.

People get their powers from organization roles — see Organization members & roles for the four roles, ownership and how tenants, groups and guardrails combine. This page is about the machine lane.

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/tenants/$TENANT_ID/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.

To give the same policies to people rather than keys, see Member groups below.

Member groups ​

Groups bundle machine principals. Member groups bundle people: an on-call rotation or a data-steward team can be set up to inherit least-privilege grants as a group, instead of minting a bespoke role per person. They speak the same vocabulary as every access policy on this page — operations, FRN targets, constraints — and they attach the same shared policy documents the machine groups above use.

Recorded, not yet enforced for people

Member groups belong to the whole organization rather than to one tenant: an organization owner or admin manages them.

In this release, the policies you attach to a member group are recorded but not yet consulted when a person's requests are evaluated — nothing in them takes effect for people yet. Members keep exactly what their organization role grants until access-policy evaluation for people is switched on platform-wide; setting your groups up now means those grants go live with that switch. Machine principals are not affected by this: access policies on API keys and workload identities already evaluate as described on this page.

Find them at Organizations → your organization → Member groups:

  1. Create a group — a name (unique within the organization) and an optional description. Names can't be edited from the portal yet; delete and recreate the group if you need a different one.
  2. Add members — pick from your organization's roster. Any admin can add or remove any member, the owner included; no one can add themselves (the portal hides the current user and the API refuses the attempt either way).
  3. Attach shared access policies — pick from the shared policies authored in your organization's tenants; you can detach one at any time. The group's members are set up to inherit everything those policies allow, on top of what their organization role already grants — see below for what takes effect in this release. (fm and Terraform cannot author member groups yet; the portal is the authoring surface.)

What member groups mean for members ​

  • Groups only add — they never take away. A member keeps their role's permissions, and each attached policy is evaluated on top of the role, never instead of it. There is no member-group setting that removes a member's role authority.
  • No member-group deny in v1. A deny rule inside a policy attached to a member group does not restrict people, and in this release the allow rules of those policies do not grant to people either — nothing attached to a member group affects human evaluation yet. When inherited authority goes live, member groups grant only additively; explicit denies stay a machine-principal concern until a later release. (Deny rules on API-key and workload-identity policies already enforce today. For a real deny floor for people, see Guardrails below.
  • No runtime ceiling. If two attached policies both allow an operation, a member has it; there is no "the group can never exceed the attaching admin's permissions" runtime check. The protection is per document at attach time: an attachment that exceeds the attaching admin's own permissions is refused.
  • Nothing to apply. There is no apply or enforce switch anywhere in the portal — the platform manages when inherited authority takes effect, and (once it is live for people) a change reaches a member at that member's next token validation: at most their current session's next token renewal.
  • Membership is durable. Membership survives role changes and suspensions: change someone's organization role and their group membership carries over. (A suspended member receives no authority at all while suspended, whatever their groups say — but the roster row remains, so review it on demotion or suspension.) Removing someone from the organization cuts their access too.
  • Deleting a group deletes its recorded membership and policy attachments for everyone in it — memberships cascade and attachments are stripped, so once inherited authority is live, its members immediately lose what the group had granted.

Guardrails ​

Member groups add authority; guardrails take it off the table. A guardrail pins a deny-only access policy at the organization level — or on one unit of your organization's structure — and its deny rules then bind everyone underneath the attachment point, your owner included. This is the floor shape enterprise governance asks for: "no role, no grant, no group twist can let anyone read these resources or delete these services." Only the platform's own staff escape a guardrail, by design.

Recorded lanes, not yet enforced for people

Guardrails share the member groups' posture: they attach and store today, and what a guardrail does to a person's requests follows the same platform-wide switch that turns on access-policy evaluation for people — the same one the member-groups section above is waiting on. Until it flips in your environment, attaching a guardrail changes nothing for anyone; when it does, the deny takes effect at that member's next token validation. Machine principals (API keys, workload identities) are never bound by guardrails — this is a people axis only.

Find them at Organizations → your organization → Guardrails (same owner-or-admin bar as member groups):

  1. Attach a guardrail — choose the scope (Organization binds everyone; a unit binds everything inside its branch of your organization's unit tree) and pin a policy that contains deny rules only. A document with allow rules is refused — a guardrail must never silently grant.
  2. Detach — the one sanctioned way to remove a floor. It is audited with the actor's name, and the members' tokens re-resolve without it on their next validation.

What guardrails mean in practice ​

  • Deny wins, full stop. A guardrail deny overrules group policies, custom roles, presets and read access alike. The owner's blanket * grants nothing a guardrail forbids.
  • Authoring refuses conflicts before they exist. The platform refuses (GUARDRAIL_CONFLICT) a guardrail that overlaps something your organization has already granted through a group or a custom role — and refuses, in the other direction, a group attachment or role edit that would collide with a standing guardrail. The refusal names the operation; narrowing the grant (or detaching the guardrail, which is itself audited) is the recovery. The platform is deliberately over-cautious here: a deny pinned to one precise resource can refuse a broader grant in the same family until you split the document.
  • The pinned document is locked. While a policy is attached as a guardrail, it cannot be edited or deleted through the ordinary policy surface (GUARDRAIL_PINNED) — a quiet edit would be a quiet relaxation. Detach first, then edit; an organization unit with a guardrail attached cannot be deleted either.
  • Units follow their attachment. A guardrail pinned to a unit reaches every tenant inside it — moving a tenant asks nothing and changes nothing on paper, but what binds it on the next token validation is whatever the unit tree now says.
  • Metadata only. The guardrails tab shows the attachment — level, unit, policy id — never the policy's contents; the rules stay on the policy's own surface.

Delegated IAM admins ​

Large organizations don't want every grant to flow through one owner. That is why estate admins can narrow a member's reach to specific tenants (Organizations → Members → edit access): the member keeps their role and every org-level duty, but their role then applies only on the tenants an explicit grant names — the same bound the tenant switcher already shows them. Tenants created later are not included; widening is always an explicit grant by an unscoped admin.

What narrowing means on the IAM authoring surfaces (member groups, policies, guardrails — everything else in this section): a tenant-scoped admin now authors inside their own reach, and the platform says so honestly.

  • Authoring. A policy document authored by a tenant-scoped admin may pin a target's tenant segment only to a tenant inside their reach — the wider-reading * is refused for them (POLICY_TARGETS_UNCOVERED). Org-wide admins keep * exactly as documented above.
  • Member groups. A tenant-scoped admin may still bundle people into member groups, but the documents they attach must be authored in one of their covered tenants and pin only covered tenants — a global template, a * pin or another tenant's pin is refused (POLICY_NOT_IN_COVERAGE). Because a pinned document only ever applies on the tenants it pins, a group's grant delivered this way cannot leak beyond the author's reach.
  • Guardrails — not delegatable. A floor above a delegate's reach is exactly what delegation must not be able to create or remove, so guardrail attach and detach stay org-wide-admin only; a tenant-scoped admin is refused (GUARDRAIL_NOT_DELEGATED) and can still read the list.
  • Coverage read failures refuse the write. If the platform cannot read a caller's coverage while they write, the write refuses — it never silently answers "org-wide". A retry succeeds when the store recovers; nothing latches.

Enforcement posture is unchanged

Delegation bounds who may author, not what the authored material does for people — everything here rides the same recorded-lanes posture the member groups and guardrails sections describe, and the platform-wide evaluation switch turns it on together with them.

Custom roles ​

Beyond the four invitation presets (viewer, member, billing, admin), an organization can carry custom roles: named permission sets drawn from the same family:action catalog every access policy on this page speaks. Custom roles are authored through the roles API today (POST /tenants/{tenant_id}/roles — the portal picker lists them, it does not author them yet).

Custom roles as invitation slots. When your organization has custom roles, the invite dialog's role picker offers them alongside the presets. A custom role is referenced by its id, must belong to your own organization, and is re-checked when the invitation is accepted. Two ceilings protect the grant:

  • An admin can only grant a set they hold — both when authoring a custom role and when inviting someone into one. A role wider than the grantor is refused (ROLE_EXCEEDS_PERMISSIONS).
  • The owner's * covers everything, so an owner can always seat any role the organization carries.

What a custom role does for its holder. The role's set travels their token exactly like a preset's does. Where evaluation for people is switched on — the same platform switch the member groups above are waiting on — the role's set is what the engine consults; until then nothing changes for their resource requests. Custom roles are always level 0 in the management ladder: they cannot be granted or changed through member management (only the invite flow seats one), but a member holding one is still fully reachable for removal, suspension, un-suspension and demotion — the containment verbs an org admin already holds.

Why was I denied? ​

Everything above adds authority in layers — presets, custom roles, group documents, guardrails, tenant reach. When a request is refused by an enforcing gate, those layers answer why, in this exact order: the platform's own staff pass first; then an explicit policy deny wins outright; reading and listing pass unless denied; your role's preset set is consulted; finally a group's policy document can grant where the preset alone would not. The Why denied? panel (Organizations → your organization → Why denied?) walks that real ladder for you — it is not a model of the rules, it re-walks the same evaluation the platform performs, on the same grant data.

What you ask. One concrete operation (compute:instances:create — no wildcards), one target (frn:compute:*:t-abc:instances/i-1), and the tenant the question is about. Every member asks about their own authority; an organization admin can also ask about another member — the fastest way to answer "my user says they can't do X" without a support ticket.

What you get back. The verdict — what an enforcing server would return today — followed by the ladder, rung by rung, in the order it was actually consulted. Each rung says what it found, and the rungs that granted or denied name the document responsible: a group policy (its id) or a guardrail (organization- or unit-level, its id). A deny shows the exact required permission the platform's own refusal would echo. The rungs a decisive answer made unnecessary show as not consulted; a rung that could not resolve shows as such rather than pretending an answer — the ladder then continues over the remaining rungs, exactly as production behaves.

Enforcement posture still applies

The panel shows what an enforcing server would return today. Whichever gates are still in observation mode only measure — nothing is refused by these lanes until your operator switches them on. And the verdict itself is read-only: asking why changes nothing — no policy is edited, nothing is recorded as a grant, and nothing you type ever reaches anyone but you.

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.