Skip to main content

Authorization Model

AuthzX's authorization model draws a clear line between what is being protected (Applications and Resources) and who is attempting access (Subjects, Roles, and Groups). Policies are the protection rules that tie the two sides together. This separation keeps identity portable across an entire tenant while letting protection scope stay as narrow — or as broad — as a given policy needs it to be.

AuthzX follows a deny-by-default model. If no policy explicitly grants access, the request is denied. You don't need to create DENY policies to block access — only to override existing ALLOW policies at equal or higher priority.

Core entities

The ownership hierarchy is intentionally flat: everything lives under a Tenant, and only Resources nest one level deeper under an Application.

EntityScopeOwned by
TenantRoot
ApplicationTenantTenant
Resource TypeTenant (global across apps)Tenant
ResourceApplication (strict, never tenant-level)App
SubjectTenantTenant
RoleTenantTenant
GroupTenantTenant
PolicyTenantTenant

Mental model

Think of an Application as a resource container. The two analogies that consistently work:

  • Microservice analogy — an app is a microservice; its resources are the endpoints, records, or objects that service owns.
  • Building analogy — an app is a building; its resources are the rooms inside that building.
  • AI agent analogy — an app is a tool or API the agent can call (Slack API, database, file system, MCP server); its resources are specific capabilities within that tool (send_message, query_table, read_file, execute_code). Policies are the guardrails on what the agent can do. A subject of type agent represents the AI agent's identity, and a role like read-only-agent or full-access-agent groups the permission profile the agent operates under.

A Resource is the thing actually being protected — an endpoint, a document, a room. Resources always belong to exactly one app; they are never tenant-level.

Policies describe the protection rules. A policy can target specific resources (narrow protection) or all resources in an app (the fan-out shortcut — "protect everything in this microservice / building"). Either form, or both at once, is valid.

Roles, Subjects, and Groups are pure identity. They answer the question "who is attempting access?" and carry no scope of their own. A role does not belong to an app; a subject is not scoped to a resource. They exist at the tenant level and can be reached by any policy the tenant defines.

Where scope lives

Scope lives only on the policy->app and policy->resource links. Nowhere else.

  • A policy's evaluative reach = the union of its linked apps (fan-out to all their resources) and its linked specific resources.
  • Roles, Subjects, and Groups have no scope field, no app binding, no resource binding.
  • Role-to-app and subject-to-app associations are organizational filters only — they help narrow what you see in the dashboard. They do not participate in authorization evaluation.

For example, a policy linked to App: Documents covers every resource in that app (fan-out). A policy linked only to doc_1 covers that single resource. A policy with no links at all is in draft state and covers nothing.

Policy "editors-can-read" → linked to App: Documents → covers doc_1, doc_2, folder_a (everything in the app)
Policy "viewers-read-only" → linked to doc_1, doc_2 → covers only those two resources
Policy "compliance-review" → linked to (nothing) → draft state, covers nothing

Actions and policies

Actions are declared on the policy, not derived from the resource type. This is a deliberate design choice with four consequences:

  1. Decoupling from resource-type churn. Renaming an action on a resource type does not cascade-break existing policies; they simply stop matching for that action until updated.
  2. Forward-referencing. A policy can declare actions: ["approve-invoice"] before any resource type with that action exists. The policy waits dormant until a matching resource appears. For example, a team can create Policy: "finance-approvers" with actions: ["approve-invoice"] today, even though the invoice resource type won't be added until next sprint. The policy simply has no matches until then.
  3. Wildcards. actions: ["*"] cleanly covers any action the subject attempts.
  4. Simple evaluation. Matching is a string-membership check against policy.actions, not a resource-type lookup.

Resource types remain the catalog of valid actions for UI assistance — the dashboard can suggest actions when a developer writes a policy against a specific resource type or an app. They do not constrain policy semantics.

Evaluation flow

When a client calls /v1/authorize, the policy engine resolves the decision roughly like this:

  1. Locate the resource's app. Every resource has exactly one application_id.
  2. Collect candidate policies. A policy is a candidate if it is:
    • linked to this specific resource, or
    • linked to the resource's app (fan-out to all resources in that app). A policy with no links at all is in draft state — it does not match any request until it's linked to at least one target.
  3. Filter by subject assignment. The calling subject must be assigned to the policy directly, via a role, or via a group.
  4. Match action and conditions. The requested action must be in the policy's actions list (or the policy declares ["*"]); ABAC conditions (time, attributes, request context) must pass.
  5. Apply DENY override via priority. If an applicable DENY policy exists at an equal or higher priority, the decision is DENY; otherwise an applicable ALLOW wins.

Examples

The following scenarios walk through the evaluation flow end-to-end. Each shows the setup, the request, and the result.

Example 1 — App-wide policy (microservice style)

App: Documents
├── Resource Type: document (actions: read, write, delete, share)
├── Resource Type: folder (actions: read, write, list)
└── Resources:
├── doc_1 (type: document)
├── doc_2 (type: document)
└── folder_a (type: folder)

Policy: "editors-can-read"
effect: ALLOW
actions: ["read"]
linked to App: Documents

Evaluation:
(alice, read, doc_1) → ALLOWED (document supports read, policy covers App)
(alice, read, folder_a) → ALLOWED (folder supports read, policy covers App)
(alice, write, doc_1) → no match (wrong action — needs a separate policy)

The policy fans out to every resource in the Documents app. Alice can read any resource because read is in the policy's action list. Her write attempt finds no matching ALLOW policy.

Example 2 — Resource-specific + DENY override

Policy: "viewers-read-only"
effect: ALLOW
actions: ["read"]
linked to: doc_1, doc_2 (specific resources)

Policy: "block-outside-hours"
effect: DENY
actions: ["*"]
conditions: time NOT between 09:00-17:00
linked to App: Documents
priority: 80

Evaluation at 20:00:
(bob, read, doc_1) → DENIED (DENY policy matches at priority 80, overrides ALLOW)

Evaluation at 14:00:
(bob, read, doc_1) → ALLOWED (DENY condition fails — outside its time gate)

At 20:00, the DENY condition passes (it is outside business hours), so the DENY takes effect and overrides the ALLOW. At 14:00, the DENY condition fails (it is within business hours), so only the ALLOW policy applies and bob can read.

Example 3 — AI agent with guardrails

App: Code Execution Service
├── Resource Type: runtime (actions: execute, read_output, kill)
└── Resources:
├── python_sandbox (type: runtime)
├── node_sandbox (type: runtime)
└── production_shell (type: runtime)

Subject: agent_copilot (type: agent)
Role: "safe-executor" groups [Policy A, Policy B]

Policy A: "sandbox-execute"
effect: ALLOW
actions: ["execute", "read_output"]
linked to: python_sandbox, node_sandbox

Policy B: "no-production"
effect: DENY
actions: ["*"]
linked to: production_shell
priority: 100

Evaluation:
(agent_copilot, execute, python_sandbox) → ALLOWED (Policy A)
(agent_copilot, execute, production_shell) → DENIED (Policy B, priority 100)
(agent_copilot, kill, python_sandbox) → no match (no ALLOW for kill)

The agent_copilot subject has the "safe-executor" role, which groups both policies. It can execute in sandboxes but is hard-blocked from production. The kill action has no matching ALLOW policy, so it defaults to no access.

Policy: "compliance-review"
effect: DENY
actions: ["delete"]
conditions: requires_approval == false
linked to: (nothing)

Status: DRAFT — this policy exists but matches nothing.
Once linked to an app or resource, it activates.

A developer can save this policy while iterating on conditions. It is invisible to the evaluation engine until at least one link is added.

Example 5 — Cross-app access via tenant-level role

App A: Billing API
└── Resources: invoice_123, payment_456

App B: Analytics Dashboard
└── Resources: report_789, dataset_abc

Role: "finance-admin" groups [Policy X, Policy Y]

Policy X: effect=ALLOW, actions=["read","write"], linked to App A
Policy Y: effect=ALLOW, actions=["read"], linked to App B

Subject: carol, assigned role "finance-admin"

Evaluation:
(carol, write, invoice_123) → ALLOWED (Policy X covers App A)
(carol, read, report_789) → ALLOWED (Policy Y covers App B)
(carol, write, report_789) → no match (Policy Y only allows read in App B)

Carol's single tenant-level role spans two apps. Each policy in the role targets a different app with different actions. No role duplication needed.

Design decisions

Twelve edge cases were resolved during the #34 design discussion. These are now locked.

  • Zero-link policy (draft state) — A policy with no app links and no resource links is valid but in draft state. It does not match any request during evaluation. This lets developers save partial policies (for example, while iterating on conditions) before linking them to targets. Activation is implicit: the moment at least one app or resource link is added, the policy becomes active. An explicit status column and transition workflow (draft -> active -> disabled -> archived) are planned for v1.1 when approval/review workflows become relevant. (See Example 4 above for a walkthrough.)
  • App-wide and resource-specific both present — When a policy links to an app and to specific resources (including resources in other apps), semantics are Union (OR). The policy protects any resource from either set. There is no intersection mode. For example, a policy linked to App: Documents AND to invoice_123 (in App: Billing) covers all resources in Documents plus invoice_123.
  • Resource immutability — A resource's application_id is immutable after creation. If a resource needs to move apps, it must be deleted and recreated in the target app. This keeps link-based evaluation consistent and avoids silent scope changes.
  • Cross-app policy access — Allowed by design. Because roles and subjects are tenant-level, a single policy can protect resources across multiple apps. This is how "platform administrator" style access is expressed without duplicating roles per app. (See Example 5 above for a cross-app scenario.)
  • DENY policies — Follow exactly the same link rules as ALLOW policies (app link, resource link, or tenant-wide). DENY's only distinguishing behavior is the priority-based override during evaluation.
  • Soft-deleted entities — A soft-deleted subject, role, policy, or group is treated as no-access during evaluation. It does not raise errors and does not match; it is simply invisible to the engine until restored.
  • Uniqueness — All tenant-level entities enforce per-tenant uniqueness on their name. You cannot have two roles named admin in the same tenant, even if they would "belong" to different apps — because roles do not belong to apps. Use explicit names like billing-admin and docs-admin if you need per-app distinction.
  • App-delete cascade — Deleting an app cascades to its resources and removes all policy, subject, and role associations with that app. Subjects, roles, policies, and groups themselves persist because they live at the tenant level. For example, deleting App: Documents removes doc_1, doc_2, and folder_a, but the "editors-can-read" policy survives (now in draft state with no links) and can be re-linked to a new app.
  • DENY + conditions — Conditions scope when a policy takes effect (for example, outside business hours, from an untrusted network). No special handling is needed for DENY + conditions; the condition simply gates whether the DENY is applicable in a given request. (See Example 2 above for a time-gated DENY in action.)
  • No hierarchy above app — Folders or org units above Application are not planned. If a customer eventually needs to group many apps (production vs staging, product suites, teams), a first-class grouping mechanism (tags, folders, or labels) will be added when real demand shows up.
  • No hierarchy below app — Sub-resources, folders, or paths below Application are not a first-class concept. Use resource attributes plus ABAC conditions. startsWith and matches operators are planned for v1.1 to make path-style policies ergonomic.
  • Performance — Authorization decisions are evaluated in single-digit milliseconds. The engine pre-computes and caches policy data so that each request resolves without additional database queries.