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:
- If any
denyrule matches, the request is denied — an explicit deny always wins, regardless of rule order or which policy it came from. - Otherwise, if any
allowrule matches, the request is allowed. - 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.
{
"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:
| Field | Required | Value |
|---|---|---|
name | no | A human label for the rule. Free text; has no effect on evaluation. |
access | yes | allow or deny. |
operations | yes | Non-empty list of operation patterns (see Operations). |
targets | yes | Non-empty list of FRN patterns (see Targets — FRN). |
constraints | no | Extra 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 operationcompute:instances:*— every action on compute instancescompute:*— 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:
fm iam catalog # all operations
fm iam catalog --filter compute # only those containing "compute"or over HTTP:
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'sfrn:storage:*:*:volumes/*— any volume, this tenant'sfrn: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:
{
"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
| Key | Meaning |
|---|---|
frn:region | Not 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:sourceIp | The caller's source IP. |
frn:tenant | The tenant id. |
frn:principalType | The principal kind: api_key or workload_identity. |
frn:currentTime | The 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
| Operator | Value shape | Notes |
|---|---|---|
equals | a single string | Exact match. |
notEquals | a single string | Negated exact match. |
like | a single string | Glob match (* wildcard). |
ipInRange | a list of CIDR strings | Used with frn:sourceIp. |
before | a single RFC3339 timestamp | Used with frn:currentTime. |
after | a single RFC3339 timestamp | Used 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
denyrule with no constraint. - To restrict a grant (to a source IP, a time window, a resource tag), put the constraint on the
allowrule.
{
"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
# 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:
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:
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:
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
# → allowConstraint 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:
{
"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:
- Author the policy (above) and test it with
fm iam simulateagainst the operations your workload actually performs. - Attach it to the key.
- Narrow the scopes on the key (the policy is additive; both the synthesized scope-policy and your attached policy are evaluated, and any explicit
denywins). Remove the broadcompute:writescope once the attached policy covers what the key needs. - 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.