Skip to content

Latest commit

 

History

History

docs

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

AccessControl Module

Development tasks:

Gma.Modules.AccessControl is the optional persisted RBAC implementation for the generic access-control framework.

It owns:

  • access principals keyed by subject kind and subject id;
  • role names and role permissions;
  • standing or temporary subject role assignments scoped by normalized AccessScope, with retained revocation history;
  • tenant-owned scoped access profiles, profile permissions, assignments, and immutable change history;
  • SQL Server and PostgreSQL migrations in the access schema;
  • the persisted IAccessDecisionProvider used by IAccessAuthorizationService;
  • the persisted IAccessGrantScopeReader used by modules that need grant scopes before building their own queries;
  • a Contracts-only lifecycle facade for exporting, closing, and incrementally erasing one non-global access-scope subtree; and
  • optional admin CLI/API front doors for bootstrap and role management;
  • an optional normal API front door for scoped profile management.

It does not own:

  • user identity, passwords, sessions, or tokens;
  • admin operation audit;
  • product-specific visibility rules or resource graph semantics;
  • product-specific HTTP, CLI, or UI front doors.

Composition

Hosts that need persisted RBAC compose this module explicitly:

builder.Services.AddAccessControlApplication(builder.Configuration);
builder.AddAccessControlPersistence();

Products must explicitly register the permission codes that may appear in scoped profiles. The default allowlist is empty and profile mutations therefore fail closed:

builder.Services.AddAccessProfilePermissionAllowlist([
    PropertiesPermissionCodes.Read,
    ReservationsPermissionCodes.Manage
]);

Registration only establishes eligibility. Create, update, and assignment operations also require the acting subject to hold every delegated permission in the profile's owning scope. Delegation is evaluated through the framework batch authorization contract, so one profile operation does not perform one provider round trip per permission.

Products may additionally register one or more IAccessProfileAssignmentPolicy implementations when profile assignment eligibility depends on product-owned state. Policies run after AccessControl's delegation checks and before persistence; any rejection fails closed. AccessControl intentionally does not know what membership, employment, or another product lifecycle means.

Products may register conjunctive IAccessProfileMutationAdmissionPolicy implementations for profile create, update, archive, and non-idempotent ensure operations. Policies receive only the generic owner scope, actor, operation, and optional profile id or key. Denied, unavailable, unknown, and failed policy evaluations leave the profile unchanged. Exact ensure replays bypass admission because they do not mutate state. Hosts that register no policy retain the module's default behavior.

Products may register conjunctive IAccessRoleAssignmentPolicy implementations for compatibility-role grants. Policies receive only the generic subject, normalized role, its ordered permission snapshot, scope, and optional UTC expiry. AccessControl rechecks that snapshot under the provider-backed management lock before writing the assignment. A role cannot gain permissions while it has an active temporary assignment; reducing its permissions remains allowed. Role-assignment scope lookups use a fixed SHA-256 index key plus the canonical scope predicate, keeping SQL Server keys bounded without relying on hash uniqueness. AccessControl also publishes post-settlement assignment lifecycle observations through IAccessRoleAssignmentLifecycleObserver; observers must keep evidence bounded and must not treat the hook as a transactional outbox.

Cross-module extensions may remove every scoped profile assignment for one subject and one exact scope through IAccessProfileAssignmentRevoker. The operation is transactional and idempotent, preserves immutable unassignment history, and does not affect compatibility roles or assignments in another scope. Lifecycle event handling belongs in an explicit extension, not in AccessControl.

Products that install named defaults or coordinate complete subject access use the Contracts-only IAccessProfileProvisioner. It can ensure a tenant-scoped profile by stable key without overwriting an existing profile, inspect exact-scope profiles and subject assignments, and atomically reconcile the complete assignment set for one subject. Reconciliation validates actor delegation and every registered assignment policy before writing, records immutable assignment history, and runs under the same provider-backed management lock as interactive profile mutations. Seed definitions and lifecycle decisions remain product-owned.

Products that expose their own role-management facade use the Contracts-only IAccessProfileManager. It provides paged profile reads, the configured permission allowlist, and create/update/archive operations through the same CQRS handlers used by the normal AccessControl API. The facade requires access-control.profiles.read or access-control.profiles.manage in the exact owner scope before dispatch; expected validation, delegation, not-found, and optimistic-concurrency failures are returned as framework results. Product modules must still authorize their own front door and translate generic profiles into product labels and resource choices.

Cross-module extensions and products that provision global compatibility roles use the Contracts-only facade:

IAccessControlRoleProvisioner provisioner = ...;
await provisioner.EnsureRoleAsync(new AccessControlRoleDefinition(
    "workspace-manager",
    ["properties.read"]), cancellationToken);
await provisioner.EnsureAssignmentAsync(
    AccessSubject.User(subjectId),
    "workspace-manager",
    workspaceScope,
    cancellationToken);

Do not consume IAccessControlRbacRepository outside AccessControl. It is a persistence-shaped Application port, not a module contract. Compatibility-role definitions supplied through the Contracts facade are reconciled exactly, so removing a seeded permission removes the persisted grant. Final-owner protection still rejects an unsafe wildcard removal.

Products that retire a product-owned scope use the Contracts-only IAccessControlScopeLifecycle. A lifecycle coordinate contains both the canonical non-global AccessScope root and the transport scope id used by the module inbox. Products own that mapping; AccessControl does not infer tenant, workspace, property, or another product concept from either value.

The lifecycle exports bounded, deterministic pages for compatibility-role assignments, access profiles, profile assignments, and profile history. Global role definitions are represented by ordered permission snapshots in the assignment export and are not erased with a scope. Assignments and history that target the selected subtree are included even when their profile is owned by an ancestor. Principals are erased only when no role or profile assignment references them anywhere.

An export is fenced by the module's global management revision, so any concurrent AccessControl management write conservatively makes the selected revision stale. Destruction first closes the scope under the same provider-backed management lock, then removes at most one non-empty bounded batch per call and finishes with an exact-request replay receipt. Access-growing application operations perform one batched closure lookup inside that lock; the DbContext write barrier is the final guard for supported direct persistence paths. Revocation and unassignment remain available after closure. Scoped inbox messages arriving after closure are suppressed, while an actively processing message makes destruction return Busy.

Closed snapshots expose both the resulting close revision and the durable selected revision. Callers must reuse that selected revision for exact retry; they must not derive it by subtracting one because the management revision is global and a previously missing scope can close after unrelated writes.

Admin hosts compose the AccessControl admin front door explicitly:

builder.AddAdminModule<AccessControlAdminCliModule>();
builder.AddAdminApiModule<AccessControlAdminApiModule>();

Those front doors register Gma.Framework.Administration.AccessControl, so admin authorization flows through generic access-control only when AccessControl is selected. Gma.Modules.Administration can still be composed by itself for audit storage and denies by default unless the host provides another IAdminAuthorizationService.

Normal API hosts can use Gma.Framework.AccessControl.AspNetCore endpoint helpers with this persisted provider without registering Administration front doors.

Modules that need bulk resource filtering should read granted access scopes once through IAccessGrantScopeReader, then translate those scopes inside the owning module's repository. Do not call IAccessAuthorizationService once per row. AccessControl still does not know what product concepts such as region, property, or package mean.

CLI Commands

AccessControl owns the compatibility admin command group:

admin bootstrap --actor <id> --yes
admin roles create --actor <id> --name <role>
admin roles grant --actor <id> --role <role> --permission <code>
admin roles revoke --actor <id> --role <role> --permission <code>
admin roles assign --actor <id> --target-kind <kind> --target-id <id> --role <role> [--scope <scope>] [--expires-at-utc <utc>]
admin roles unassign --actor <id> --target-kind <kind> --target-id <id> --role <role> [--scope <scope>]
admin roles assignments --actor <id> --role <role> [--include-inactive] [--page <n>] [--page-size <n>] [--output table|json]
admin roles list --actor <id> [--page <n>] [--page-size <n>] [--output table|json]

Supported target kinds are user, admin-actor, service, and system. --target-actor remains a compatibility alias for --target-id, with admin-actor as the CLI default. Subject kind is part of identity: user/member-a and admin-actor/member-a are distinct principals.

Existing assignment API clients may continue sending actorId; it is treated strictly as an admin-actor identity. New clients should send explicit subjectKind and subjectId, which are required for every non-admin subject.

Revocation and unassignment take effect immediately and remain reviewable. Expiry is evaluated inside persisted authorization, so a lease stops authorizing at its stored UTC boundary without waiting for a cleanup worker. The final active global admin-actor owner wildcard assignment is protected from both unassignment and wildcard revocation so an operator cannot permanently lock every administration surface. Temporary owner-wildcard assignments are rejected.

bootstrap creates the first owner principal and succeeds only when there are no existing assignments unless configuration explicitly allows bootstrap over existing assignments. The persisted implementation reserves bootstrap through a provider-backed singleton gate inside the same transaction as role/assignment creation, so concurrent first-owner attempts cannot both succeed.

Admin API

AccessControl owns the compatibility role routes:

GET  /api/admin/roles
POST /api/admin/roles
POST /api/admin/roles/{roleName}/permissions
DELETE /api/admin/roles/{roleName}/permissions/{permissionCode}
POST /api/admin/roles/{roleName}/assignments
GET  /api/admin/roles/{roleName}/assignments
DELETE /api/admin/roles/{roleName}/assignments?subjectKind=<kind>&subjectId=<id>&scope=<scope>

Bootstrap remains CLI-only.

All management list surfaces normalize page and page-size through framework PageRequest, order deterministically, and fetch one extra row for HasMore instead of issuing an unbounded count.

Scoped Profile API

Normal API hosts compose AccessControlApiModule. Its routes are independent of Administration:

GET    /api/access-control/profiles?scope=<scope>&page=<n>&pageSize=<n>
GET    /api/access-control/profiles/permissions?scope=<scope>
GET    /api/access-control/profiles/{profileId}?scope=<scope>
POST   /api/access-control/profiles?scope=<scope>
PUT    /api/access-control/profiles/{profileId}?scope=<scope>
POST   /api/access-control/profiles/{profileId}/archive?scope=<scope>
GET    /api/access-control/profiles/{profileId}/assignments?scope=<scope>&page=<n>&pageSize=<n>
POST   /api/access-control/profiles/{profileId}/assignments?scope=<scope>
DELETE /api/access-control/profiles/{profileId}/assignments?scope=<scope>&subjectKind=<kind>&subjectId=<id>
GET    /api/access-control/profiles/{profileId}/history?scope=<scope>&page=<n>&pageSize=<n>

Every route requires authentication and resolves a non-global owning scope before enforcing access-control.profiles.read, access-control.profiles.manage, or access-control.profiles.assign. Profile keys are unique only inside that owning scope. Archived profiles and their assignments remain queryable for traceability but stop authorizing immediately.

Profile update and archive requests carry an expected version. Provider concurrency and unique-key races are retried once through the CQRS pipeline so the second evaluation returns a stable application conflict rather than leaking a database exception.

Transactional AccessControl commands acquire the module's provider-backed management lock for the lifetime of their database transaction. Profile assignment eligibility, the assignment write, and exact-scope bulk revocation therefore cannot pass one another and leave a late grant behind. Product assignment policies must read current authoritative eligibility, and lifecycle handlers must make the subject ineligible before calling IAccessProfileAssignmentRevoker; a product that derives eligibility from compatibility membership assignments removes those assignments first.

Configuration

{
  "AccessControl": {
    "Bootstrap": {
      "OwnerRoleName": "owner",
      "AllowWhenAssignmentsExist": false
    }
  }
}

BootstrapOwnerCommand creates the initial owner role and global owner assignment only when confirmed and when no assignments exist, unless AllowWhenAssignmentsExist is explicitly enabled. SQL Server and PostgreSQL migrations create the bootstrap gate; apply them before using the command.

Scope Model

AccessControl answers this generic question:

Can subject S perform permission P in scope X?

Scopes are normalized framework values such as:

  • global
  • tenant:tenant-a
  • tenant:tenant-a/property:property-1

The persisted provider keeps concrete permission grants exact-scope by default. A role granted catalog.items.read at tenant:tenant-a does not automatically inherit into tenant:tenant-a/property:property-1, and a global concrete grant does not automatically authorize tenant scopes. The bootstrap owner wildcard remains the compatibility escape hatch that uses global and ancestor scope matching.

Product modules still own the meaning of property, region, department, or any other resource segment. A permission may explicitly opt into descendant-scope inheritance in its descriptor:

new ModulePermissionDescriptor(
    "properties.read",
    "Read visible properties.",
    PermissionScopeRequirement.Scoped,
    PermissionScopeGrantPolicy.Descendants)

The product module registers those policies explicitly during composition:

builder.Services.AddGmaAccessControlPermissionPolicies(PropertiesModuleMetadata.Descriptor);

Registration is explicit and idempotent. Unregistered permissions remain exact-scope, and conflicting policies for the same permission code fail during service registration. PermissionScopeGrantPolicy.Descendants lets a tenant assignment cover matching property descendants but does not make a global assignment universal; global inheritance is a separate explicit policy.

For list/detail filtering, the intended split is:

AccessControl
  -> granted AccessScope values for subject + permission

Owning module
  -> domain visibility policy
  -> typed query scope
  -> module-owned SQL predicate

Point authorization and IAccessGrantScopeReader resolve the same descriptor policy. Callers should use AccessGrantScope.Grants(...) when checking a requested scope in memory or translate the same exact/ancestor/global semantics deliberately in their own persistence layer.

Boundaries

Modules declare permission codes in contracts/metadata. Role definitions are operator configuration. A product may constrain which role names and permission sets are eligible for a sensitive assignment through IAccessRoleAssignmentPolicy; those product decisions do not belong in AccessControl.

AccessControl references framework contracts and persistence helpers only. It must not reference Auth internals, Administration internals, NATS, Redis, or product modules.

Its persistence package owns a broker-neutral inbox. Cross-module extensions may therefore bind durable integration-event consumers to AccessControl without moving idempotency or transaction ownership into the producing module.

Point checks and profile-delegation checks share the same persisted authorization semantics. The persisted provider groups a batch by subject and scope, reads the union of relevant role and active-profile grants once per group, then applies each permission descriptor's scope policy in memory. Callers should still use IAccessGrantScopeReader and module-owned SQL predicates for large resource lists.

Verification

Run the complete local gate with Docker available:

./eng/verify.ps1

The gate checks architecture boundaries, a zero-warning build, SQL Server and PostgreSQL migration drift, fast tests, package vulnerabilities, and real SQL Server and PostgreSQL race/integration tests. Use -SkipDocker only for a deliberately reduced local pass; CI keeps both relational-provider proofs in a separate required Linux job.