The situation
A SaaS document-management app has grown past its authorisation story. The app supports:
- Users authenticated via a Cognito user pool, each with attributes (department, role, team membership).
- Documents owned by users, shared with other users or teams, organised into folders and workspaces.
- Actions: view, edit, share, approve, delete.
- Context: time of day (some documents have edit windows), IP (corp network vs public), MFA presence.
Current state: the authorisation logic is ~2,800 lines of if-statements across 40 handlers in the Go codebase. Adding a new permission rule (e.g. “only team leads can approve documents in the finance workspace”) requires a code deploy. Auditing “who can edit document X right now” is not meaningfully possible. Different handlers have inconsistent interpretations of the same rule.
The team is evaluating Amazon Verified Permissions as the managed externalised authorisation layer.
What actually matters
Authorisation has a shape most applications don’t recognise until they’ve rewritten it three times. It’s a decision problem: given a principal (the user), an action (what they want to do), a resource (what they want to do it to), and some context (MFA presence, IP, time), return ALLOW or DENY. The classical pattern is to code that decision into each request handler; the modern pattern is to externalise it to a policy engine.
Externalising has three benefits worth naming.
Policies as artefacts. The rules live as declarative policies, not as scattered code. An auditor asks “what’s the policy for approving finance documents?” The answer is a named policy file, not “let me check three handlers.” A security engineer can read the policy set without reading the application code.
Consistency across services. If the same user + action + resource question appears in multiple services (a web app and a mobile API and a background batch job), they all call the same authorisation engine with the same policies. No drift between handlers.
Schema-enforced resource hierarchy. Authorisation often relates entities hierarchically: a document belongs to a folder, a folder belongs to a workspace, a user belongs to a team. A policy like “users in team X can edit documents in workspace Y” is expressible once, against the hierarchy, rather than as a per-document check.
A policy language fit for this decision is declarative, with principals, actions, resources, and optional clauses for context, evaluated under a specific combination algorithm (typically: explicit DENY wins, then at least one ALLOW, otherwise DENY).
Cost shape is the other property to weigh. A per-authorisation-request engine charges on every check; for applications that make many authz decisions per user-request (e.g. a feed that filters 50 items), batched evaluation reduces both cost and latency, and is one of the things to look for in the candidates.
What we’ll filter on
- Policy language. Imperative code or declarative policy?
- Resource model. Flat or hierarchical?
- Context richness. Attributes, MFA claims, time, IP?
- Evaluation mode. Single request or batched?
- Integration path. SDK, API Gateway Lambda authoriser, custom?
The authorisation landscape
1. Amazon Verified Permissions. Managed Cedar-based policy engine. Create a policy store, define a schema describing principal types, resource types, and actions, write policies, call IsAuthorized or BatchIsAuthorized from the application. Priced per request. Integrates with Cognito for identity-source-backed policy stores.
2. Code-based authorisation (the status quo). if-statements in request handlers, possibly backed by a role-permissions table. No policy engine; decision logic is distributed across the codebase.
3. OPA (Open Policy Agent) / Rego. Cloud-agnostic open-source policy engine. Self-hosted (as a sidecar or centralised service), Rego policy language. Similar concept to Verified Permissions; more operational work.
4. Oso / Casbin / Spicedb. Commercial and open-source alternatives to OPA. Each has its own policy language, hierarchy model, and operational shape.
5. IAM for application authorisation. Using IAM roles/policies to gate application-level access. Works when the principals are AWS identities; poorly suited to arbitrary app users because IAM’s scale is ~5,000 entities per account.
6. Cognito user-pool groups. Cognito supports groups with associated IAM roles. Fine for coarse role mapping; doesn’t handle per-resource or hierarchical rules.
Side by side
| Option | Language | Resources | Context | Evaluation | Identity integration |
|---|---|---|---|---|---|
| Verified Permissions | Cedar | Hierarchical via schema | Rich attributes | Single + batched | Cognito-native |
| Code-based | Whatever the app language is | App-defined | App-defined | App-defined | App-defined |
| OPA / Rego | Rego | App-defined | Rich | Single + batched | App-defined |
| Oso / Casbin / Spicedb | Vendor-specific | Varies | Varies | Varies | Varies |
| IAM | Policy JSON | AWS resources | Limited | Single | IAM |
| Cognito groups | Role mapping | Coarse | Token claims | Per request | Cognito-native |
Reading the table: Verified Permissions is the managed Cedar path with Cognito integration. The alternatives are either self-hosted (OPA), commercial (Oso), or built-in but limited (IAM, Cognito groups).
The authorisation request shape
The picks in depth
Schema and policy store. Create a policy store in Verified Permissions; link it to the Cognito user pool as an identity source (so user attributes from the Cognito ID token are automatically available). Define a schema with principal types (User, Team), resource types (Document, Folder, Workspace), and actions (editDocument, viewDocument, shareDocument, approveDocument, deleteDocument). Resource hierarchy: Document is in Folder is in Workspace. The schema tells Verified Permissions the shape of the entities; policies reference entities by type-and-ID.
Example policies. Cedar syntax is readable:
// Document owners can do anything to their documents
permit (
principal,
action,
resource
) when {
resource has owner && resource.owner == principal
};
// Team members can edit documents in workspaces their team has been granted
permit (
principal,
action == Action::"editDocument",
resource
) when {
principal has teams &&
resource.Workspace.grantedTeams.containsAny(principal.teams)
};
// Approvals require MFA, team lead role, and corp-network IP
permit (
principal,
action == Action::"approveDocument",
resource
) when {
principal.role == "lead" &&
principal.mfa_present == true &&
context.ip.isInRange("10.0.0.0/8")
};
// Deny hits first: no edits on locked documents
forbid (
principal,
action == Action::"editDocument",
resource
) when {
resource.status == "locked"
};
Verified Permissions evaluates: if any forbid matches, DENY. Otherwise, if any permit matches, ALLOW. Otherwise, DENY by default.
Calling from the app. The application’s request handler, instead of computing authorisation itself:
input := &verifiedpermissions.IsAuthorizedInput{
PolicyStoreId: aws.String(storeID),
Principal: entity("User", userEmail),
Action: action("Action", "editDocument"),
Resource: entity("Document", docID),
Context: contextMap("ip", clientIP, "mfa_present", mfa),
Entities: entitiesWithHierarchy(doc, folder, workspace, user, teams),
}
out, _ := vpClient.IsAuthorized(ctx, input)
if out.Decision != types.DecisionAllow {
return status.Error(codes.PermissionDenied, "not allowed")
}
The application doesn’t know why the request is allowed or denied; it just enforces. The determining policy ID is returned in the response for logging, so the audit trail says “request allowed by policy abc123.”
Batched evaluation for feeds. The document feed returns 50 docs; for each, the app needs to know if the user can edit. Instead of 50 sequential IsAuthorized calls, one BatchIsAuthorized call evaluates 50 (principal, action, resource) triples in one trip. Same policy evaluation semantics, one network round-trip, one billing event at 50x the per-request price (still cheaper than 50 separate calls’ network overhead).
A worked authz decision
Alice is a payments team member attempting to edit document 42 (in Folder “finance-q4”, in Workspace “finance”). Alice has MFA present, is on the corp network (10.0.1.5), has role “member” (not “lead”). The editDocument handler calls IsAuthorized:
- Forbid policies evaluated first. None matches (document 42 is not
locked). - Permit policies. The “team members can edit documents in granted workspaces” policy evaluates: Alice’s teams is
[payments], document’s workspace’sgrantedTeamsis[payments, finance], overlap exists,whenclause (no conditions) evaluates true. ALLOW.
Response: Decision: ALLOW, DeterminingPolicies: [policy-team-edit-workspace]. The handler proceeds with the edit. An audit log entry captures “user alice@acme.com editing document 42 authorised by policy-team-edit-workspace at 2028-08-26T14:30:00Z.”
Bob, a different user in the marketing team, tries the same action. IsAuthorized evaluates the same policies. Marketing team is not in grantedTeams for the finance workspace; no permit policy matches; no forbid policy matches either. Default deny. Response: Decision: DENY.
A worked policy rollout
The team wants to add a new rule: “archived documents cannot be edited or shared.” Policy:
forbid (
principal,
action in [Action::"editDocument", Action::"shareDocument"],
resource
) when {
resource.status == "archived"
};
- Author the policy in a pull request. The policy is a file in a
policies/directory in the repo. - CI runs
cedar validateagainst the schema, catches any type errors. - CI runs
cedar testwith a suite of test cases (principal, action, resource, context, expected decision). Adding the new rule includes adding test cases. - Merge; CD pipeline creates the policy in Verified Permissions via
aws verifiedpermissions create-policy. - Effect is immediate across every service that calls
IsAuthorizedagainst this policy store. No application deploy needed.
That’s the real win: policy changes decouple from application deploys. A security engineer who doesn’t write Go can update the authorisation logic for the Go app.
What’s worth remembering
- Verified Permissions separates authorisation from authentication. Cognito handles “who is this”; VP handles “what can they do.” Two services, two decisions.
- Cedar is the policy language. Declarative, with principals, actions, resources, context. Forbid wins over permit; default deny.
- Policy stores have schemas. Schemas define entity types and their attributes; policies are validated against the schema.
- Hierarchical resources are first-class. Documents in folders in workspaces evaluate policies applied at any level.
IsAuthorizedis the single-request call.BatchIsAuthorizedhandles many decisions in one round-trip for feed-style use cases.- Cognito user-pool integration pulls attributes automatically. Claims from the ID token become principal attributes without extra plumbing.
- Policies deploy independently of application code. Authz changes don’t require a code release; they require a policy release.
- Default deny. If no permit policy matches, the answer is DENY. Explicit forbid is for the cases you want to make unambiguous.
Authentication tells you who’s knocking. Authorisation tells you what they can do once they’re inside. Verified Permissions is the managed service that makes the second question a policy, not a code change.