Kaizen
Browse modulesAccessaccess/serverClasses

Class: GrantService

Defined in: server/access/grant-service.ts:110

Authoring surface for grants, with the privilege-escalation guards baked in:

  • Grant-time subset check (canGrant): every permission the granted role confers must be held by the actor AND be delegable.
  • Role metadata gates: the role must be assignableByOrgAdmins; a systemManaged role is only org-grantable when it opts in.
  • Allowed grant scopes: the target scope must be listed, including on the trusted system path. This constrains assignment, not existing grants.
  • Platform-scope invariant: scopeType = "platform" requires a GLOBAL role (organizationId: null) that is systemManaged AND lists "platform" in allowedGrantScopes — an org-owned role can never be granted platform-wide authority, regardless of its other metadata.

The actor's authority is derived across the scope chain, so a platform admin can delegate inside an organization through this guarded path rather than needing mode: "system" (which would skip every escalation guard above).

In v1 every can() compiles a fresh snapshot, so create/revoke take effect immediately with no cache to invalidate (the per-subject grant-version counter is deferred — see docs/rbac/design.md).

Extends

Constructors

Constructor

new GrantService(deps: AccessFeatureDeps): GrantService;

Defined in: server/access/base-access-service.ts:36

Parameters

ParameterType
depsAccessFeatureDeps

Returns

GrantService

Inherited from

BaseAccessService.constructor

Properties

deps

protected readonly deps: AccessFeatureDeps;

Defined in: server/access/base-access-service.ts:34

Inherited from

BaseAccessService.deps

Accessors

actorId

Get Signature

get protected actorId(): string | null;

Defined in: server/access/base-access-service.ts:40

Returns

string | null

Inherited from

BaseAccessService.actorId

Methods

assertCanGrant()

assertCanGrant(rolePermissions: `${string}.${string}`[], actorPermissions: `${string}.${string}`[]): void;

Defined in: server/access/grant-service.ts:594

Can the actor grant a role conferring rolePermissions? Every conferred permission must be held by the actor and (if a catalog is wired) be delegable. Throws PrivilegeEscalationError otherwise.

H3: actorPermissions MUST be the actor's UNCONDITIONALLY-held set — see coarsePermissionsForActor. Passing a permission the actor only holds via a selector/condition-narrowed grant would let it be re-delegated as an unconstrained grant; deriving the set with hasCoarsePermission notion (no selector AND no condition) closes that.

Parameters

ParameterType
rolePermissions`${string}.${string}`[]
actorPermissions`${string}.${string}`[]

Returns

void


checkCreateGrant()

checkCreateGrant(input: {
  condition?: ConditionLogicNode;
  effectiveEnd?: Date | null;
  effectiveStart?: Date | null;
  id?: string;
  roleId: string;
  scope:   | {
     organizationId: string;
     type: "organization";
   }
     | {
     type: "platform";
   };
  selector?: {
     resourceType: string;
     where: Record<string, string | number | boolean | string[] | null>;
  };
  subject: {
     id: string;
     type: "user" | "group" | "service_account";
  };
}, options: CreateGrantOptions): Promise<GrantVerdict>;

Defined in: server/access/grant-service.ts:254

The verdict create would reach for this exact input — a dry run, and genuinely non-throwing: an unknown or version-less role comes back as a denial, which is what an authoring UI hits when a role is deleted between page load and the check.

Takes the full CreateGrantInput, not a role id, so the attribute-registry check runs too: "would accept" is a lie for any grant carrying a selector or condition unless its paths are validated as well.

Parameters

ParameterType
input{ condition?: ConditionLogicNode; effectiveEnd?: Date | null; effectiveStart?: Date | null; id?: string; roleId: string; scope: | { organizationId: string; type: "organization"; } | { type: "platform"; }; selector?: { resourceType: string; where: Record<string, string | number | boolean | string[] | null>; }; subject: { id: string; type: "user" | "group" | "service_account"; }; }
input.condition?ConditionLogicNode
input.effectiveEnd?Date | null
input.effectiveStart?Date | null
input.id?string
input.roleIdstring
input.scope| { organizationId: string; type: "organization"; } | { type: "platform"; }
input.selector?{ resourceType: string; where: Record<string, string | number | boolean | string[] | null>; }
input.selector.resourceTypestring
input.selector.whereRecord<string, string | number | boolean | string[] | null>
input.subject{ id: string; type: "user" | "group" | "service_account"; }
input.subject.idstring
input.subject.type"user" | "group" | "service_account"
optionsCreateGrantOptions

Returns

Promise<GrantVerdict>


checkGrantAllowed()

checkGrantAllowed(args: {
  actor?: CoarseActorAuthority;
  roleId: string;
  roleOrganizationId: string | null;
  scope: GrantScope;
  version: {
     allowedGrantScopes: JsonValue;
     assignableByOrgAdmins: boolean;
     changeReason: string | null;
     createdAt: Date;
     createdBy: string | null;
     deletedAt: Date | null;
     deletedBy: string | null;
     id: string;
     maxAssignableScope: RbacMaxAssignableScope;
     permissions: JsonValue;
     roleId: string;
     systemManaged: boolean;
     versionNumber: number;
  };
}): GrantVerdict;

Defined in: server/access/grant-service.ts:346

The full authorization gate cluster for a create, as a VERDICT, in order:

  1. Tenant-boundary invariant — always enforced, even on the system path: an org-owned custom role (roleOrganizationId non-null) may only be granted within ITS OWN organization. A global/system role (roleOrganizationId === null) has no home org and is grantable in any organization scope.
  2. Platform-scope invariant — always enforced, even on the system path: scopeType = "platform" requires a GLOBAL role (roleOrganizationId === null) that is systemManaged and whose allowedGrantScopes includes "platform" — an org-owned role can never gain platform-wide authority via contradictory metadata (e.g. systemManaged: true + allowedGrantScopes: ["platform"] set on a role that still has an organizationId). Every target scope must also appear in allowedGrantScopes, including organization scope.
  3. Escalation guards (org-facing guarded path only — skipped for system): role-assignment metadata gate, maxAssignableScope (M1), and the grant-time subset check (canGrant).

An actor of undefined means the trusted system path.

This is the single source of truth for "may this be granted?" — create, revoke, checkCreateGrant, and listGrantableRoles all route through it, so none of them can drift from the others.

Parameters

ParameterType
args{ actor?: CoarseActorAuthority; roleId: string; roleOrganizationId: string | null; scope: GrantScope; version: { allowedGrantScopes: JsonValue; assignableByOrgAdmins: boolean; changeReason: string | null; createdAt: Date; createdBy: string | null; deletedAt: Date | null; deletedBy: string | null; id: string; maxAssignableScope: RbacMaxAssignableScope; permissions: JsonValue; roleId: string; systemManaged: boolean; versionNumber: number; }; }
args.actor?CoarseActorAuthority
args.roleIdstring
args.roleOrganizationIdstring | null
args.scopeGrantScope
args.version{ allowedGrantScopes: JsonValue; assignableByOrgAdmins: boolean; changeReason: string | null; createdAt: Date; createdBy: string | null; deletedAt: Date | null; deletedBy: string | null; id: string; maxAssignableScope: RbacMaxAssignableScope; permissions: JsonValue; roleId: string; systemManaged: boolean; versionNumber: number; }
args.version.allowedGrantScopesJsonValue
args.version.assignableByOrgAdminsboolean
args.version.changeReasonstring | null
args.version.createdAtDate
args.version.createdBystring | null
args.version.deletedAtDate | null
args.version.deletedBystring | null
args.version.idstring
args.version.maxAssignableScopeRbacMaxAssignableScope
args.version.permissionsJsonValue
args.version.roleIdstring
args.version.systemManagedboolean
args.version.versionNumbernumber

Returns

GrantVerdict


coarsePermissionsForActor()

coarsePermissionsForActor(
   actor: {
  id: string;
  type: SubjectType;
}, 
   scope: GrantScope, 
   now?: Date
): Promise<CoarseActorAuthority>;

Defined in: server/access/grant-service.ts:630

H3: derive the actor's UNCONDITIONALLY-held authority for a target scope — the delegable set + footing create derives internally from CreateGrantGuard.actor. Public override of the shared BaseAccessService derivation (also used by RoleService.createVersion's guard) so this stays part of GrantService's public API.

Parameters

ParameterType
actor{ id: string; type: SubjectType; }
actor.idstring
actor.typeSubjectType
scopeGrantScope
nowDate

Returns

Promise<CoarseActorAuthority>

Overrides

BaseAccessService.coarsePermissionsForActor


create()

create(input: {
  condition?: ConditionLogicNode;
  effectiveEnd?: Date | null;
  effectiveStart?: Date | null;
  id?: string;
  roleId: string;
  scope:   | {
     organizationId: string;
     type: "organization";
   }
     | {
     type: "platform";
   };
  selector?: {
     resourceType: string;
     where: Record<string, string | number | boolean | string[] | null>;
  };
  subject: {
     id: string;
     type: "user" | "group" | "service_account";
  };
}, options: CreateGrantOptions): Promise<{
  conditionLogic: JsonValue;
  createdAt: Date;
  createdBy: string | null;
  deletedAt: Date | null;
  deletedBy: string | null;
  effectiveEnd: Date | null;
  effectiveStart: Date | null;
  grantFingerprint: string;
  id: string;
  organizationId: string | null;
  roleId: string;
  scopeKey: string;
  scopeType: RbacScopeType;
  selector: JsonValue;
  subjectId: string;
  subjectType: RbacSubjectType;
  updatedAt: Date;
  updatedBy: string | null;
}>;

Defined in: server/access/grant-service.ts:112

Create a grant, failing on a duplicate of the same active tuple.

Parameters

ParameterType
input{ condition?: ConditionLogicNode; effectiveEnd?: Date | null; effectiveStart?: Date | null; id?: string; roleId: string; scope: | { organizationId: string; type: "organization"; } | { type: "platform"; }; selector?: { resourceType: string; where: Record<string, string | number | boolean | string[] | null>; }; subject: { id: string; type: "user" | "group" | "service_account"; }; }
input.condition?ConditionLogicNode
input.effectiveEnd?Date | null
input.effectiveStart?Date | null
input.id?string
input.roleIdstring
input.scope| { organizationId: string; type: "organization"; } | { type: "platform"; }
input.selector?{ resourceType: string; where: Record<string, string | number | boolean | string[] | null>; }
input.selector.resourceTypestring
input.selector.whereRecord<string, string | number | boolean | string[] | null>
input.subject{ id: string; type: "user" | "group" | "service_account"; }
input.subject.idstring
input.subject.type"user" | "group" | "service_account"
optionsCreateGrantOptions

Returns

Promise<{ conditionLogic: JsonValue; createdAt: Date; createdBy: string | null; deletedAt: Date | null; deletedBy: string | null; effectiveEnd: Date | null; effectiveStart: Date | null; grantFingerprint: string; id: string; organizationId: string | null; roleId: string; scopeKey: string; scopeType: RbacScopeType; selector: JsonValue; subjectId: string; subjectType: RbacSubjectType; updatedAt: Date; updatedBy: string | null; }>


ensure()

ensure(input: {
  condition?: ConditionLogicNode;
  effectiveEnd?: Date | null;
  effectiveStart?: Date | null;
  id?: string;
  roleId: string;
  scope:   | {
     organizationId: string;
     type: "organization";
   }
     | {
     type: "platform";
   };
  selector?: {
     resourceType: string;
     where: Record<string, string | number | boolean | string[] | null>;
  };
  subject: {
     id: string;
     type: "user" | "group" | "service_account";
  };
}, options: CreateGrantOptions): Promise<{
  conditionLogic: JsonValue;
  createdAt: Date;
  createdBy: string | null;
  deletedAt: Date | null;
  deletedBy: string | null;
  effectiveEnd: Date | null;
  effectiveStart: Date | null;
  grantFingerprint: string;
  id: string;
  organizationId: string | null;
  roleId: string;
  scopeKey: string;
  scopeType: RbacScopeType;
  selector: JsonValue;
  subjectId: string;
  subjectType: RbacSubjectType;
  updatedAt: Date;
  updatedBy: string | null;
}>;

Defined in: server/access/grant-service.ts:140

Idempotent create: returns the existing ACTIVE grant instead of failing when the identical (scope, subject, role, fingerprint) tuple is already granted. Every authorization gate still runs first — this only changes what a duplicate does, never who may create one.

The duplicate is resolved by a READ BEFORE the insert, never by catching the constraint. TransactionManager reuses an ambient transaction with no savepoint, so when a consumer calls this inside their own transaction a unique violation puts the whole transaction into Postgres' aborted state — and the follow-up read needed to resolve the collision would fail with 25P02, killing the caller's transaction and everything it had done. That is precisely the nested, provisioning-rail case ensure exists for.

A genuine concurrent race still surfaces P2002 from the partial unique index, deliberately: recovering from it requires the very read the aborted transaction can no longer serve, so the honest move is to let the caller retry. (Prisma cannot upsert here either — uniqueness is a PARTIAL index, WHERE deleted_at IS NULL so revoke-then-re-grant works, and a partial index is not expressible in the schema.)

Parameters

ParameterType
input{ condition?: ConditionLogicNode; effectiveEnd?: Date | null; effectiveStart?: Date | null; id?: string; roleId: string; scope: | { organizationId: string; type: "organization"; } | { type: "platform"; }; selector?: { resourceType: string; where: Record<string, string | number | boolean | string[] | null>; }; subject: { id: string; type: "user" | "group" | "service_account"; }; }
input.condition?ConditionLogicNode
input.effectiveEnd?Date | null
input.effectiveStart?Date | null
input.id?string
input.roleIdstring
input.scope| { organizationId: string; type: "organization"; } | { type: "platform"; }
input.selector?{ resourceType: string; where: Record<string, string | number | boolean | string[] | null>; }
input.selector.resourceTypestring
input.selector.whereRecord<string, string | number | boolean | string[] | null>
input.subject{ id: string; type: "user" | "group" | "service_account"; }
input.subject.idstring
input.subject.type"user" | "group" | "service_account"
optionsCreateGrantOptions

Returns

Promise<{ conditionLogic: JsonValue; createdAt: Date; createdBy: string | null; deletedAt: Date | null; deletedBy: string | null; effectiveEnd: Date | null; effectiveStart: Date | null; grantFingerprint: string; id: string; organizationId: string | null; roleId: string; scopeKey: string; scopeType: RbacScopeType; selector: JsonValue; subjectId: string; subjectType: RbacSubjectType; updatedAt: Date; updatedBy: string | null; }>


listGrantableRoles()

listGrantableRoles(actor: {
  id: string;
  type: SubjectType;
}, scope: GrantScope): Promise<CompiledRole[]>;

Defined in: server/access/grant-service.ts:287

Every role the actor could actually grant in scope — what a role-picker should contain. Built OUT OF the create gate rather than beside it, so it cannot drift into offering a role the server then refuses.

Attribute-registry validation is NOT part of this answer — it depends on a specific grant's selector/condition, which a role list does not have. Use checkCreateGrant once the caller has composed one.

Parameters

ParameterType
actor{ id: string; type: SubjectType; }
actor.idstring
actor.typeSubjectType
scopeGrantScope

Returns

Promise<CompiledRole[]>


loadActiveGrants()

protected loadActiveGrants(
   repos: AccessRepositories, 
   subject: Subject, 
   scopes: GrantScope[]
): Promise<{
  conditionLogic: JsonValue;
  createdAt: Date;
  createdBy: string | null;
  deletedAt: Date | null;
  deletedBy: string | null;
  effectiveEnd: Date | null;
  effectiveStart: Date | null;
  grantFingerprint: string;
  id: string;
  organizationId: string | null;
  roleId: string;
  scopeKey: string;
  scopeType: RbacScopeType;
  selector: JsonValue;
  subjectId: string;
  subjectType: RbacSubjectType;
  updatedAt: Date;
  updatedBy: string | null;
}[]>;

Defined in: server/access/base-access-service.ts:70

Security-critical lookup: load a subject's active grants across a set of scopes, expanding org-group membership for non-group subjects. The single source of truth for "which grants apply to this subject right now" — every caller (snapshot compilation, coarse-permission derivation) funnels here so the scopeKey derivation and group-expansion rule can never drift between call sites.

Queried one scope at a time rather than with a single scopeKey IN (…): group expansion is a PER-SCOPE rule, not a property of the subject. It runs only for an organization-scoped lookup AND a non-group subject — a group subject's grants are read directly (it has no "groups" of its own to expand), and platform scope has no org-group notion, so a group's platform-scoped grant must NOT reach its members through an org-scoped chain. Flattening the chain into one query with a unioned groupIds would silently widen exactly that.

Must be called inside a repos.transaction(...) so the membership reads and the grant reads share one transaction.

Parameters

ParameterType
reposAccessRepositories
subjectSubject
scopesGrantScope[]

Returns

Promise<{ conditionLogic: JsonValue; createdAt: Date; createdBy: string | null; deletedAt: Date | null; deletedBy: string | null; effectiveEnd: Date | null; effectiveStart: Date | null; grantFingerprint: string; id: string; organizationId: string | null; roleId: string; scopeKey: string; scopeType: RbacScopeType; selector: JsonValue; subjectId: string; subjectType: RbacSubjectType; updatedAt: Date; updatedBy: string | null; }[]>

Inherited from

BaseAccessService.loadActiveGrants


revoke()

revoke(id: string, options: CreateGrantOptions): Promise<{
  conditionLogic: JsonValue;
  createdAt: Date;
  createdBy: string | null;
  deletedAt: Date | null;
  deletedBy: string | null;
  effectiveEnd: Date | null;
  effectiveStart: Date | null;
  grantFingerprint: string;
  id: string;
  organizationId: string | null;
  roleId: string;
  scopeKey: string;
  scopeType: RbacScopeType;
  selector: JsonValue;
  subjectId: string;
  subjectType: RbacSubjectType;
  updatedAt: Date;
  updatedBy: string | null;
}>;

Defined in: server/access/grant-service.ts:220

Soft-delete (revoke) a grant. Revocation is immediate: the next can() compiles a fresh snapshot that no longer sees this grant (v1 has no cache to invalidate).

Fails closed exactly like create: an authorization mode is REQUIRED. On the guarded path the actor must reach the same verdict create would for this grant's own role and scope — if you could not create it, you may not revoke it. Without this an engine that guards creation but not destruction lets any caller holding a grant id revoke it in any tenant.

Parameters

ParameterType
idstring
optionsCreateGrantOptions

Returns

Promise<{ conditionLogic: JsonValue; createdAt: Date; createdBy: string | null; deletedAt: Date | null; deletedBy: string | null; effectiveEnd: Date | null; effectiveStart: Date | null; grantFingerprint: string; id: string; organizationId: string | null; roleId: string; scopeKey: string; scopeType: RbacScopeType; selector: JsonValue; subjectId: string; subjectType: RbacSubjectType; updatedAt: Date; updatedBy: string | null; }>


transaction()

protected transaction<T>(fn: (deps: AccessFeatureDeps) => Promise<T>): Promise<T>;

Defined in: server/access/base-access-service.ts:44

Type Parameters

Type Parameter
T

Parameters

ParameterType
fn(deps: AccessFeatureDeps) => Promise<T>

Returns

Promise<T>

Inherited from

BaseAccessService.transaction

On this page