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 bedelegable. - Role metadata gates: the role must be
assignableByOrgAdmins; asystemManagedrole 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 issystemManagedAND lists"platform"inallowedGrantScopes— 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
| Parameter | Type |
|---|---|
deps | AccessFeatureDeps |
Returns
GrantService
Inherited from
Properties
deps
protected readonly deps: AccessFeatureDeps;Defined in: server/access/base-access-service.ts:34
Inherited from
Accessors
actorId
Get Signature
get protected actorId(): string | null;Defined in: server/access/base-access-service.ts:40
Returns
string | null
Inherited from
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
| Parameter | Type |
|---|---|
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
| Parameter | Type |
|---|---|
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.roleId | string |
input.scope | | { organizationId: string; type: "organization"; } | { type: "platform"; } |
input.selector? | { resourceType: string; where: Record<string, string | number | boolean | string[] | null>; } |
input.selector.resourceType | string |
input.selector.where | Record<string, string | number | boolean | string[] | null> |
input.subject | { id: string; type: "user" | "group" | "service_account"; } |
input.subject.id | string |
input.subject.type | "user" | "group" | "service_account" |
options | CreateGrantOptions |
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:
- Tenant-boundary invariant — always enforced, even on the
systempath: an org-owned custom role (roleOrganizationIdnon-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. - Platform-scope invariant — always enforced, even on the
systempath:scopeType = "platform"requires a GLOBAL role (roleOrganizationId === null) that issystemManagedand whoseallowedGrantScopesincludes"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 anorganizationId). Every target scope must also appear inallowedGrantScopes, including organization scope. - Escalation guards (org-facing
guardedpath only — skipped forsystem): 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
| Parameter | Type |
|---|---|
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.roleId | string |
args.roleOrganizationId | string | null |
args.scope | GrantScope |
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.allowedGrantScopes | JsonValue |
args.version.assignableByOrgAdmins | boolean |
args.version.changeReason | string | null |
args.version.createdAt | Date |
args.version.createdBy | string | null |
args.version.deletedAt | Date | null |
args.version.deletedBy | string | null |
args.version.id | string |
args.version.maxAssignableScope | RbacMaxAssignableScope |
args.version.permissions | JsonValue |
args.version.roleId | string |
args.version.systemManaged | boolean |
args.version.versionNumber | number |
Returns
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
| Parameter | Type |
|---|---|
actor | { id: string; type: SubjectType; } |
actor.id | string |
actor.type | SubjectType |
scope | GrantScope |
now | Date |
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
| Parameter | Type |
|---|---|
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.roleId | string |
input.scope | | { organizationId: string; type: "organization"; } | { type: "platform"; } |
input.selector? | { resourceType: string; where: Record<string, string | number | boolean | string[] | null>; } |
input.selector.resourceType | string |
input.selector.where | Record<string, string | number | boolean | string[] | null> |
input.subject | { id: string; type: "user" | "group" | "service_account"; } |
input.subject.id | string |
input.subject.type | "user" | "group" | "service_account" |
options | CreateGrantOptions |
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
| Parameter | Type |
|---|---|
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.roleId | string |
input.scope | | { organizationId: string; type: "organization"; } | { type: "platform"; } |
input.selector? | { resourceType: string; where: Record<string, string | number | boolean | string[] | null>; } |
input.selector.resourceType | string |
input.selector.where | Record<string, string | number | boolean | string[] | null> |
input.subject | { id: string; type: "user" | "group" | "service_account"; } |
input.subject.id | string |
input.subject.type | "user" | "group" | "service_account" |
options | CreateGrantOptions |
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
| Parameter | Type |
|---|---|
actor | { id: string; type: SubjectType; } |
actor.id | string |
actor.type | SubjectType |
scope | GrantScope |
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
| Parameter | Type |
|---|---|
repos | AccessRepositories |
subject | Subject |
scopes | GrantScope[] |
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
| Parameter | Type |
|---|---|
id | string |
options | CreateGrantOptions |
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
| Parameter | Type |
|---|---|
fn | (deps: AccessFeatureDeps) => Promise<T> |
Returns
Promise<T>