Skip to content

Authorization service ​

Authorization owns scoped roles, explicit permission bundles, role assignments, and direct member allow/deny overrides. Admin and Organization own the HTTP entry points. Identity owns authentication; membership eligibility is read through owner-provided public capabilities. The module runs inside the existing API process.

Shared service placeholder ​

internal/services.AuthorizationService satisfies types.IAuthorizationService and is exposed through Module.Authorization() or the standalone module.NewAuthorizationService() factory. Its AdminHasPermission and OrgHasPermission methods currently return true, nil without evaluating input. Eligibility and grant evaluation for this shared contract remain TODOs and will use Authorization-owned functions rather than Admin/Organization module membership readers. The API constructs this placeholder before Admin and injects it into CreateOrganizationUseCase. Other consumers continue to use the implemented permission checkers returned by Module.Permissions() with their existing wiring.

Public capabilities ​

Other services import services/authorization/public and receive only their required interfaces from the composition root:

  • HasOrganizationPermission(ctx, input) (bool, error)
  • HasAdminPermission(ctx, input) (bool, error)

Both fail closed. A normal denial is false, nil; unknown/wrong-domain permission keys, invalid input, unavailable dependencies, and storage failures return errors. Callers authenticate first and stop on either a denial or an error. Organization checks require a valid organization ID. There is no fallback to platform access. Identity and organization IDs must use canonical lowercase, hyphenated UUIDs so alternate spellings cannot bypass member version checks.

Ownership is a separate pure function:

  • IsOrganizationResourceOwner(input) bool: compares nonempty user IDs and matching organization IDs.
  • IsAdminResourceOwner(input) bool: compares nonempty user IDs for a platform resource.

The ownership functions do not authenticate, read the database, verify membership, or grant access. Inputs must come from server-loaded records. Each resource service defines ownership; a creator ID is not automatically an owner. A doctor profile ID must be resolved to its owning identity user ID before comparing it with a user ID.

For a record action, select own when the ownership helper succeeds, and any otherwise. Always select own for an owned record, even when an any grant exists: otherwise an explicit own denial would not match.

go
access := authorizationpublic.AccessAny
if authorizationpublic.IsOrganizationResourceOwner(
    authorizationpublic.OrganizationResourceOwnershipInput{
        UserID: principal.UserID,
        OrganizationID: organizationID,
        ResourceOrganizationID: resource.OrganizationID,
        OwnerUserID: resource.OwnerUserID,
    },
) {
    access = authorizationpublic.AccessOwn
}

allowed, err := permissions.HasOrganizationPermission(ctx,
    authorizationpublic.HasOrganizationPermissionInput{
        UserID: principal.UserID,
        OrganizationID: organizationID,
        Permission: "org.patient.update",
        Access: access,
    },
)
if err != nil {
    return err
}
if !allowed {
    return authorizationpublic.ErrForbidden
}

Load the resource within the authorized organization before evaluating ownership. The example assumes this resource defines OwnerUserID; it does not add an owner field to existing DTOs. Lifecycle, author-only prescription rules, subscription eligibility, and transaction/version checks remain in their owning service.

Grant semantics ​

A permission catalog entry declares its domain, resource, action, and whether it supports ownership access. own/ any is an access restriction, separate from the platform/organization scope.

Stored grantOwn checkAny check
allow ownAllowNo match
allow anyAllowAllow
deny ownDenyNo match
deny anyDenyDeny

All matching denials override role/direct allows. No matching allow means deny. An omitted check access defaults to any. Grant writes require explicit access. An own grant is rejected when the permission does not support ownership.

Custom roles and assignments are editable through HTTP. The supported permission catalog is application-defined. Adding an action requires an enforcing use case, a catalog change, a migration, and tests. No arbitrary expressions or scripts are accepted as permissions. Catalog entries for contract-only operations describe future consumers; their existence does not implement those business operations.

Membership and bootstrap authority ​

The API composition root combines narrow Admin/Organization membership readers and the Identity account directory. Checks require a current active identity and eligible membership. Organization access also requires an active organization. Inactive identities/members cannot use stored grants.

Platform super_admin and ordinary organization admin memberships are owner-designated bootstrap authority. They receive the protected virtual owner role containing explicit catalog permissions for that scope. Custom roles cannot create owner authority. This role cannot be edited, assigned, revoked, or overridden through authorization management. Membership lifecycle remains owner-managed; the legacy organization deletion route cannot remove an admin.

Ordinary platform admins and organization staff receive no automatic custom grants: an owner assigns roles/overrides through Authorization. Only authz_* tables store roles, permissions, and assignments. The baseline migrations no longer create or import legacy Admin/Organization role and permission tables.

Assignments are bound to membership incarnation IDs. Inactivation blocks use; deleting and recreating a membership produces a new ID and does not revive old assignments. Doctor affiliations remain documentation-first in Organization; their persistent reader must extend the organization-owned membership boundary when those workflows are implemented. A doctor profile or role string alone does not manufacture an affiliation.

HTTP management ​

Prefixes:

  • Admin: /api/v1/admin/authorization
  • Organization: /api/v1/organizations/{organizationId}/authorization
MethodSuffixCapability
GET/permissionsList supported permissions
GET, POST/rolesList/create roles
GET, PUT, DELETE/roles/Read/replace metadata/delete role
PUT/roles/{roleId}/permissionsReplace complete permission bundle
GET/meInspect caller roles, overrides, and contextual effective permissions
GET/members/Inspect another member
PUT, DELETE/members/{userId}/roles/Assign/revoke role
PUT, DELETE/members/{userId}/permissions/{permission}/Set/remove an override

userId is an Identity user ID, not a membership record ID. Actor identity comes from the bearer token; scope comes from the route. Both are excluded from bodies. The role identifier owner is readable but protected. New role IDs are UUIDs.

Role creation example:

json
{
  "name": "Patient editor",
  "slug": "patient_editor",
  "description": "Edit records owned by this member",
  "permissions": [
    {"permission": "org.patient.update", "access": "own"}
  ]
}

Creation returns 201. Role metadata and permission replacement bodies include expected_version (starting at 1). Metadata replacement requires name and description. Assign-role bodies include the member authorization's expected_version (starting at 0). Override bodies additionally require effect: "allow" | "deny". DELETE requests carry expected_version as a required query parameter. Stale writes return 409. Deleting a role atomically removes its assignments and increments affected member versions; successful role deletion returns 200 with data: {}. Other successful member edits return the updated authorization view in data. All implemented authorization successes use the common message, status_code, data envelope.

Removing an override restores role inheritance; it does not insert a deny. Only matching-role permissions and direct overrides contribute to effective permissions. Inspection returns separate own and any decisions.

Authorization reads require admin.authorization.read / org.authorization.read, except that an active member can inspect their own authorization. Role-definition changes require *.authorization.roles.manage; assignments and overrides require *.authorization.assign.

Delegated managers can grant/revoke only permissions they themselves hold at the requested access. Changes validate both the old and new role bundles, so editing an already assigned role cannot bypass delegation controls. Only scope owners can delegate authorization-administration permissions. At most 200 custom roles per scope and 500 grant entries per role are accepted.

Storage, migrations, and verification ​

The shared authz_* tables store scopes, the catalog, roles, role permissions, membership-bound subjects, assignments, and overrides. Composite foreign keys prevent cross-organization role assignments and admin permissions on organization roles. Scope locks serialize mutations; reads use repeatable-read snapshots. Persistence updates changed rows within one transaction, with no cross-request permission cache.

Apply migrations with go run ./tools/cli database migrate. The authorization set runs after Identity, Admin, and Organization and uses goose_authorization_version. For an existing migrated installation, --service authorization applies only the new set. Development clean/seed includes the new tables and restores the catalog.

Working Admin organization/user routes and preserved legacy organization routes now enforce their configured permissions. Previously documentation-first doctor, patient, prescription, medicine, billing, and organization contracts remain 501 operations. Runtime and offline OpenAPI both include authorization endpoints.

Authorization mutations now append through the shared Audit service inside their existing scope transaction. Role deletion and affected member revocations are audited atomically. Admin and Organization retain all HTTP audit endpoints; audit read access requires admin.audit.read or org.audit.read.

Run go test -race -short ./services/authorization/... ./services/admin/... ./services/organization/... ./services/api/... ./tools/cli/... from the root. PostgreSQL integration tests use AUTHORIZATION_TEST_DATABASE_URL and skip in short mode. Run the database migration integration test before the HTTP integration test against a dedicated local test database; the HTTP test expects loopback port 55433. Unit tests cover deny/ownership semantics, isolation, delegation, protection, revocation, membership reincarnation, and concurrent version conflicts.

See ADR-017 and the Go reference.

Documentation follows the source. Planned contracts are marked explicitly.