> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alfiz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Grants, Revokes, and Effective Access

> Learn the grant tuple that powers every access decision in Alfiz (subject, pattern, scope, expiry) and how personal revokes override group grants.

Every authorization decision in Alfiz reduces to one atomic data structure: the grant row. Roles, group memberships, public access, approved requests, machine scopes, and time-bounded elevations are all expressed as, or ultimately resolve to, the same tuple of four fields. Understanding how that tuple works, and how the single negative layer (the personal revoke) interacts with it, gives you a complete mental model of how Alfiz evaluates every `can()` call.

## The grant row

A grant row is the fundamental unit of access. Its TypeScript shape is:

```ts theme={null}
interface GrantRow {
  id: string;
  subject: SubjectId;
  roleId?: string | undefined;
  pattern?: PermissionPattern | undefined;
  /** Scope instance id, or `*`. A grant with no scope is a grant at `*`. */
  scope: ScopeId;
  /** Epoch ms. An expired grant stops matching but remains for audit. */
  expiresAt?: number | undefined;
  provenance: Provenance;
  createdAt: number;
}
```

Exactly one of `roleId` or `pattern` is set on any given row. A grant either names a role (and inherits all that role's patterns) or carries a raw permission pattern directly. The scope defaults to `*` (the global scope) when omitted. The `provenance` field records who or what created the row; it is required on every write.

<Note>
  A grant with `scope: "*"` satisfies every scoped check, because the global scope is in every object closure. Granting someone `docs.files.read` globally means they can read every document, regardless of which folder it lives in.
</Note>

## Provenance

Every grant and every revoke carries a `provenance` field. It is a required part of every write, validated before any row is stored:

```ts theme={null}
type Provenance =
  | { kind: "admin"; actorUserId: string }
  | { kind: "request"; requestId: string; approvedBy?: string }
  | { kind: "dissolution"; virtualParentId: string; originalGrantId: string }
  | { kind: "merge"; source: string }
  | { kind: "import"; source: string }
  | { kind: "reconciler"; integrationId: string }
  | { kind: "system"; note?: string };
```

Provenance answers the audit question "where did this row come from?" for every row in the system, whether it was created by an administrator, approved from a request, copied during a virtual-parent dissolution, bulk-imported from a directory sync, or produced by an integration reconciler.

## Creating a grant

Use `app.createGrant(input)` to write a single grant row. The full input shape is:

```ts theme={null}
interface GrantInput {
  subject: SubjectId;
  roleId?: string | undefined;
  pattern?: PermissionPattern | undefined;
  scope?: ScopeId | undefined;
  expiresAt?: number | undefined;
  provenance: Provenance;
}
```

<CodeGroup>
  ```ts Pattern grant (global) theme={null}
  await app.createGrant({
    subject: "user:alice",
    pattern: "docs.files.read",
    // scope omitted → defaults to "*"
    provenance: { kind: "admin", actorUserId: "root" },
  });
  ```

  ```ts Role grant (scoped) theme={null}
  await app.createGrant({
    subject: "group:editors",
    roleId: "role_editor",
    scope: "docs.folder:9",
    provenance: { kind: "admin", actorUserId: "root" },
  });
  ```

  ```ts Time-bound grant theme={null}
  await app.createGrant({
    subject: "user:contractor_42",
    pattern: "docs.files.read",
    scope: "docs.folder:9",
    expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
    provenance: { kind: "admin", actorUserId: "root" },
  });
  ```
</CodeGroup>

For bulk writes such as migrations, tenant provisioning and directory syncs, use `app.createGrants(inputs, provenance)`. It validates every input before writing any row, emits one audit entry for the batch, and fires one invalidation event per distinct subject rather than one per row.

## Revoking access

A revoke is a personal exclusion. Only individual users may hold revokes; groups and service principals do not. The revoke row shape is:

```ts theme={null}
interface RevokeRow {
  id: string;
  userId: string;
  pattern: PermissionPattern;
  scope: ScopeId;
  provenance: Provenance;
  createdAt: number;
}
```

The input type mirrors the grant input:

```ts theme={null}
interface RevokeInput {
  userId: string;
  pattern: PermissionPattern;
  scope?: ScopeId | undefined;
  provenance: Provenance;
}
```

```ts theme={null}
await app.createRevoke({
  userId: "alice",
  pattern: "docs.files.delete",
  scope: "docs.folder:9",
  provenance: { kind: "admin", actorUserId: "root" },
});
```

## Negative always wins, scope-inclusively

A revoke at any scope suppresses matching access at that scope **and every descendant scope**, regardless of where the positive grant sits. This rule is fixed and not configurable.

The consequences are deliberate:

* A revoke at `docs.folder:9` suppresses grants on `docs.doc:123` (a child of folder 9), even if the grant was made directly on that document.
* A global revoke (`scope: "*"`) suppresses matching access everywhere.
* A scoped revoke does not suppress access in *other* subtrees. It is surgical rather than a global erasure.

<Warning>
  A typo'd revoke pattern would silently fail open, which is the one direction a mistake must never take. Alfiz validates that every revoke pattern exists in the catalog and rejects unknown patterns at write time.
</Warning>

## Effective access formula

Alfiz evaluates a check by combining three things:

1. **The subject closure.** The user, every group they belong to (and those groups' ancestors), their organizations, and `everyone`.
2. **The object closure.** The target scope, every ancestor scope, and `*`.
3. **The revoke set.** The user's personal revokes whose pattern matches and whose scope appears anywhere in the object closure.

The check passes when at least one unexpired grant row connects a member of the subject closure to a member of the object closure with a matching pattern, **and** no personal revoke at any scope in the object closure suppresses it.

The `checkKey` function that makes this decision is:

```ts theme={null}
function checkKey(
  ctx: CheckContext,
  key: PermissionKey,
  objectClosure: readonly ScopeId[],
): boolean
```

It returns `true` only when `matchedRevokes` is empty and `matchedGrants` is non-empty.

## Expiry

Grants accept an optional `expiresAt` (epoch milliseconds). An expired grant stops matching checks exactly as a deleted one would, but the row remains in storage for audit purposes, so you can always answer "who had access to this document last Tuesday."

```ts theme={null}
// Grant that expires in 24 hours
await app.createGrant({
  subject: "user:contractor_42",
  pattern: "docs.files.read",
  scope: "docs.doc:123",
  expiresAt: Date.now() + 86_400_000,
  provenance: { kind: "admin", actorUserId: "root" },
});
```

<Tip>
  Use `can.fresh({ userId }, key, scope)` for operations that follow a just-granted time-bounded elevation. It bypasses the subject-side cache and re-evaluates immediately.
</Tip>

## Listing a user's effective permissions

Call `alfiz.heldKeys(principal)` to get every permission key the principal holds at any scope. This is the union of all grant-matched keys across the subject closure, filtered by global-scope revokes:

```ts theme={null}
const keys = await alfiz.heldKeys({ userId: "alice" });
// e.g. ["docs.files.read", "docs.files.update_file"]
```

<Note>
  `heldKeys` answers "what can this person do anywhere?" It uses `keyHeldAnywhere` semantics: a scoped revoke narrows one subtree but does not erase a key held elsewhere. This is the right question for conditional UI, meaning "should this button exist at all", when the concrete scope is not yet known.
</Note>

To understand *why* a specific check passed or failed, use `alfiz.explain(principal, key, scope?)`. It returns a `CheckExplanation` with the matched grants, matched revokes, and the final `allowed` verdict:

```ts theme={null}
interface CheckExplanation {
  allowed: boolean;
  /** Unexpired grants that would allow the key at this scope. */
  matchedGrants: GrantRow[];
  /** Revokes that suppress it (non-empty forces `allowed: false`). */
  matchedRevokes: RevokeRow[];
}

const result = await alfiz.explain({ userId: "alice" }, "docs.files.delete", "docs.folder:9");
console.log(result.allowed);        // false
console.log(result.matchedRevokes); // [{ pattern: "docs.files.delete", scope: "docs.folder:9", ... }]
```

The explanation is purely data-derived. "Why can (or can't) Alice do this here" is answerable from rows, without re-deriving anything by hand.
