Policy documents
Versioned JSON policies, from statement fields and limits to how grants and boundaries combine, policy variables, versions, testing, and lint.
say who does which job. documents say what a job actually allows, precisely enough for a computer to decide. A policy document is a list of . Each statement says "allow" or "deny", names the it covers and the it applies to, and can add : tests on the request, such as "the session used MFA" or "the caller owns this document".
Because the format is plain JSON, policies can be stored, versioned, reviewed in pull requests, tested before they are saved, and linted for mistakes. The same format is used for everything that limits access too: , ceilings, trust ceilings, and session policies.
This document lets its holders read every document, edit and delete their own documents from an MFA session, and never modify archived ones:
{
"version": 1,
"statements": [
{
"sid": "ReadDocuments",
"effect": "allow",
"actions": ["documents:read"],
"resources": ["document/*"]
},
{
"sid": "WriteOwnDocumentsWithMfa",
"effect": "allow",
"actions": ["documents:write", "documents:delete"],
"resources": ["document/*"],
"conditions": {
"StringEquals": { "resource.ownerId": "${principal.id}" },
"Bool": { "principal.mfa": true }
}
},
{
"sid": "NeverTouchArchived",
"effect": "deny",
"actions": ["documents:write", "documents:delete"],
"resources": ["document/archive-*"]
}
]
}Try this document in the playground
In TypeScript, write documents with definePolicy from better-iam. It validates the document when your code
loads, so a malformed policy fails at startup rather than when it is saved, and it returns a detached copy with
its literal types preserved.
Document fields
A document has two fields:
Prop
Type
Each statement:
Prop
Type
Documents are validated when they are stored and again before they are evaluated. Anything outside these limits
is rejected with INVALID_POLICY:
- The document and each statement are plain objects, and unknown fields are rejected.
- Action and resource patterns are nonempty strings of at most 512 characters without control characters.
conditionsis a nonempty map of known operators. Each operator lists 1 to 64 keys, each key is 1 to 128 characters (and never__proto__,prototype, orconstructor), and each key has 1 to 64 expected values of the operator's type. String values are at most 2048 characters.- Every
${...}must be a well-formed policy variable.
Stored documents are also checked against the permission catalog:
unknown exact actions fail with INVALID_ACTION, and unknown exact resource types with INVALID_RESOURCE_TYPE.
Actions and resource patterns
A statement rarely names one action on one resource. Patterns let it cover a family, such as "every document" or
"every documents action". Patterns are anchored globs: * matches any run of characters, including none and
including / and :, and ? matches exactly one character. Nothing else is special: patterns are never regular
expressions, so a . or + in a name means just that character.
| Pattern | Matches |
|---|---|
documents:read | Exactly that action. |
documents:* | Every action in the documents namespace, including ones added later. |
* | Every action, or every resource. |
document/q3-plan | One resource. |
document/* | Every resource of type document. |
*/report | Resources with ID report of any type. |
iam/* | Every administrative resource of the tenant. |
iam/task/apollo-* | Administration of task resources whose IDs start with apollo-. |
A request's resource is the string type/id (at most 2048 characters), inside a tenant that was resolved before
evaluation. A pattern cannot reach another tenant, however it is written.
How statements combine
A person usually holds several roles, each with several statements, and some of them may disagree. The combining rules decide the outcome, and they are designed so that adding a restriction is always safe: a deny cannot be undone by another role's allow.
A statement applies to a request when one of its action patterns matches the action, one of its resource patterns matches the resource, and all of its conditions hold. The are the documents a person holds through their roles; the boundaries are the ceiling documents described below. The evaluator combines every applicable statement:
- . If any applicable statement is a deny, the request is denied
(
explicit-deny), whatever else allows it. This includes deny statements inside boundaries. - Grants union. Otherwise the request needs at least one applicable allow among the grants (
no-grantwhen there is none). - Boundaries intersect. Every boundary is independent, and each one must contain an applicable allow.
One boundary without a match denies the request (
boundary-deny). No boundaries at all means no restriction. - Only then is the request
allowed.
Boundaries constrain; they never grant access. An allow inside a boundary only lets grants through.
You can run the same evaluator anywhere, which is handy in unit tests for your policies. evaluatePolicy, exported
from better-iam, takes an action, a resource string, grant and boundary documents, and a context, and returns
the decision. It is also what the policy playground runs in the browser:
import { evaluatePolicy } from 'better-iam';
const decision = evaluatePolicy({
action: 'documents:write',
resource: 'document/q3-plan',
grants: [
{ version: 1, statements: [{ sid: 'Write', effect: 'allow', actions: ['documents:*'], resources: ['document/*'] }] },
],
boundaries: [
{ version: 1, statements: [{ effect: 'allow', actions: ['documents:*'], resources: ['*'], conditions: { Bool: { 'principal.mfa': true } } }] },
],
context: { 'principal.mfa': true },
});
// { allowed: true, reason: 'allowed', matched: ['grant:0:Write', 'boundary:0:0'] }matched lists every applicable statement as grant:{document}:{sid} or boundary:{document}:{sid}, using the
statement's position when it has no sid. The offline evaluator knows nothing about tenants or identities: the
caller must establish trusted context and tenant scope. On the server, the context is built for you (see
Context keys).
Public authorization responses omit matched statements. The administrator-only
simulation API returns them, and policies.test returns
them for candidate documents.
Boundaries
Grants answer "what may this person do?". A boundary answers a different question: "what is the most anyone here may ever do, whatever their roles say?". It is a policy document used as a ceiling. You reach for one when the people writing roles should not be the last line of defense: a platform operator fencing off a trial tenant, an integration key that must only read, or a delegated administrator who must never grant billing access.
Several kinds of boundary can apply to one request, and each one is an independent intersection:
| Boundary | What it is for | Set with |
|---|---|---|
| Tenant boundary | Caps everything in a tenant and its descendants, for example a plan that excludes webhooks. | tenants.setBoundary (root only, iam:boundaries:update, recent authentication), or boundary on tenants.create |
| Principal boundary | Caps one identity in one tenant, whatever roles it later receives. | identities.setBoundary (root only, iam:boundaries:update) |
| Grant-authority ceiling | Caps every role, policy, and binding issued under a delegated authority or below it. | authorities.create; see Grant authorities |
| Trust ceiling | Caps role sessions started through one trust. | trust.create({ ceiling }); see Role sessions |
| Session policy | Caps one role session or one API key below what its identity could do. | roles.assume({ policy }), credentials.create({ policy }), or scopes |
| Credential authority | Caps every request made with an API key at its issuing authority's ceilings, and a session exchanged from an external identity provider's token at the ceilings of whoever registered that provider. | Automatic |
tenants.setBoundary replaces the boundary of one tenant, and identities.setBoundary replaces the boundary of
one person or service account. Both are platform-controlled: only root administrators may call them, so tenant
administrators cannot lift their own ceilings. Delegation can create a narrower child authority while retaining
its parent chain, and there is no arbitrary policy-containment solver, so a ceiling is enforced when a request is
evaluated rather than proved when a role is created.
await iam.api.tenants.setBoundary(rootCredential, {
tenantId,
boundary: {
version: 1,
statements: [
{ effect: 'allow', actions: ['*'], resources: ['*'] },
{ effect: 'deny', actions: ['iam:webhooks:*'], resources: ['*'] },
],
},
});See a boundary override an administrator role
The grant allows every administrative action, and the boundary still denies creating a webhook. Change the action to
iam:roles:create and the same administrator is allowed, because the boundary's broad allow covers it.
Policy variables
"People may edit their own documents" is one rule, but without variables it would need one statement per person.
A fills in a value from the request instead. A resource pattern
or a string condition value may reference a trusted context key as ${key}, and the value is substituted before
matching, so one statement can describe "the caller's own" resources:
{
"effect": "allow",
"actions": ["documents:*"],
"resources": ["document/*"],
"conditions": { "StringEquals": { "resource.ownerId": "${principal.id}" } }
}{ "effect": "allow", "actions": ["folders:read"], "resources": ["folder/home-${principal.id}"] }The rules:
- Where. Variables work in resource patterns after the resource type (the part before the first
/), in the values of theString*operators, and in the values listed forArrayContainsandArrayContainsAll. They may not appear in action names or in the resource type segment; either is rejected when the document is stored. - Syntax. A key starts with a letter or underscore and continues with letters, digits,
_,.,:, or-, up to 128 characters. A malformed reference such as${principal.idis rejected when the document is stored. - Literal substitution. The substituted value always matches literally: a value containing
*or?cannot widen a pattern. - Unresolved never matches. A variable whose key is absent from the context, or holds a list, does not resolve, and the pattern or value that uses it never matches. Strings, numbers, and booleans resolve.
- Any trusted key. Any key from
resolveContextcan be referenced, so${principal.department}works once the application supplies it (or it is a declared identity attribute).
Unresolved variables and negated operators
A negated operator inverts a comparison that failed, so StringNotEquals against an unresolved variable
evaluates true. Add Exists: { "principal.department": true } to such statements. Likewise,
StringLikeIgnoreCase lowercases its pattern, variable names included, so a variable whose key has capital
letters never resolves there; use StringLike or a lowercase key. The linter reports both.
Manage stored policies
An inline role document is fine for a role's own permissions. When several roles share the same rules, store them once as a policy and attach it to each role, so a fix reaches all of them. Stored policies are also versioned: each save keeps the previous document, so you can see what changed and roll back a bad edit without rebuilding the old document by hand.
const policy = await iam.api.policies.create(credential, {
tenantId,
name: 'Document editors',
document: editorsDocument,
});
// Updates require the version you read (optimistic concurrency).
const updated = await iam.api.policies.update(credential, {
tenantId,
policyId: policy.id,
version: policy.version,
document: revisedDocument,
});
const history = await iam.api.policies.listVersions(credential, { tenantId, policyId: policy.id });
await iam.api.policies.restoreVersion(credential, { tenantId, policyId: policy.id, version: 1 });policies.createstores a new policy at version 1. It requiresiam:policies:createand records the caller's grant authority. The tenant'spoliciesplan limit, when set, applies (LIMIT_EXCEEDED).policies.updatesaves a new document, name, or description as the next version. It requiresiam:policies:updateand theversionyou read, so two administrators editing at once cannot silently overwrite each other: a stale version fails withVERSION_CONFLICT(409). An update with nothing to change fails withINVALID_INPUT.policies.listVersionsreturns the policy's history, every version oldest first, including the current one.policies.restoreVersionrolls back to an earlier document by saving it as a new version, so history is never rewritten. It re-validates the old document against today's catalog: a document that names an action removed since then fails withINVALID_ACTION. Restoring the current version fails withINVALID_INPUT.policies.deleteremoves a policy no role uses any more. It is refused while any role attaches the policy (RESOURCE_IN_USE).policies.getreturns one policy andpolicies.listevery policy of the tenant; both requireiam:policies:read.
Only the holder of the authority that created a policy, or root, can edit or delete it, and the protected
Owner policy cannot be changed at all (PROTECTED_RESOURCE). Attach policies to roles with policyIds on
roles.create and roles.update.
Test a document before saving it
A policy that is wrong in production either locks people out or lets them in. Two tools catch mistakes before a document is saved: a test that evaluates it against sample requests, and a linter that reads it for common errors.
policies.test evaluates a candidate document that is not stored against an action, a resource string, and a
context you supply, and returns the full decision with the matched statements. It requires
iam:policies:simulate and validates the document against the catalog first. It is meant for policy editors and
CI: it creates no session and grants nothing.
const decision = await iam.api.policies.test(credential, {
tenantId,
document: candidate,
action: 'documents:write',
resource: 'document/alice-notes',
context: { 'principal.id': 'alice', 'principal.mfa': true, 'resource.ownerId': 'alice' },
});The context holds at most 200 keys. principal.tenantId and resource.tenantId default to the tenant and
request.time to the current time; your context may override them. To see how a stored configuration treats a
real person, use policies.simulate instead.
Lint
Some policies are valid but almost certainly not what their author meant: a deny that can never apply, an allow that a deny always cancels, a condition on a key the server never sets. The linter finds these.
analysis.lintPolicy({ tenantId, document }) or analysis.lintPolicy({ tenantId, policyId }) checks a candidate
or stored document the way the server will evaluate it. It requires iam:policies:read. It first validates the
document against the catalog, returning valid: false with the error (such as INVALID_ACTION) when that
fails, and otherwise returns warnings sorted by statement. Pass contextKeys to declare keys your
resolveContext supplies, so the linter does not report them as unknown. A warning means the document likely
does not do what it says; info is worth a look but often intended.
| Code | Severity | What it reports |
|---|---|---|
unrestricted-admin | warning | An unconditional allow of * or iam:* on *: every holder is a full administrator. |
service-wildcard | info | An unconditional service-wide wildcard such as documents:*, which also grants actions added later. |
unknown-context-key | warning | A condition key or variable the server never sets (for example request.ip) and the configuration does not declare. |
optional-key-deny | warning | A deny conditioned on a key that can be missing (principal.authMethod, principal.mfaTime, request.sourceIp, session tags, identity attributes, resource attributes, application keys), which silently never applies without an Exists guard. |
negated-variable | warning | A negated string operator whose variable may not resolve, which then evaluates true. |
ignorecase-variable | warning | StringLikeIgnoreCase with a variable whose key has capitals, which never resolves. |
array-key-string-operator | warning | A string operator on principal.groups, principal.roles, or a relation list, where only ArrayContains works. |
array-variable | warning | A variable that names a list, which never resolves. |
type-mismatch | warning | An operator that can never match the key's type, such as NumericEquals on a boolean. |
always-true-condition | info | Bool or Exists listing both true and false. |
duplicate-statement | info | A statement that repeats an earlier one. |
shadowed-allow | warning | An allow whose every action and resource an unconditional deny also covers, so it never grants anything. |
deny-only | info | A document without allow statements: it grants nothing, though its denies still restrict every holder. |
shadowed-allow-skipped | info | The shadowing check ran out of its work budget, so adversarial documents stay fast. |
lintPolicy is also exported from better-iam/server for offline use, for example in CI. The offline form checks
the document's structure but not your tenant's catalog; pass identityAttributes, resourceAttributes, and
contextKeys as its second argument to describe your deployment.
import { lintPolicy } from 'better-iam/server';
const result = lintPolicy(document, { identityAttributes: { department: 'string' }, contextKeys: ['app.plan'] });
for (const warning of result.warnings) console.log(warning.severity, warning.code, warning.message);The access analysis (analysis.findings) reports stored policies and inline role documents with lint warnings as
policy-lint findings. See Access reviews.
Next steps
Better IAM is created by Sean Filimon
Last updated
Roles and bindings
Custom roles built from permission lists or policy documents, role inheritance, bindings to people and groups, and delegated grant authorities.
Conditions
Every condition operator, how values combine, negation and missing-key rules, and the context keys the server provides to policies.