Skip to content

ADR-007: Policy-Based Access Control (PBAC) ​

Status ​

Superseded implementation design by ADR-017. The resource-specific business restrictions below remain applicable.

Context ​

We need fine-grained access control for both admin and organization sides. We needed to decide:

  • RBAC (Role-Based): Simple roles (admin, doctor, staff)
  • PBAC (Policy-Based): Policies with conditions (e.g., "doctor can only edit own prescriptions")
  • ABAC (Attribute-Based): Complex attribute evaluation

Decision ​

We will use Policy-Based Access Control (PBAC) with conditions evaluated at runtime.

Implementation direction ​

Configurable authorization uses a custom service module with scoped roles, permissions and per-user overrides. The unused pkg/authz and pkg/policy packages have been removed; the replacement service is implemented in services/authorization. See ADR-017 for the current public API and grant model. Identity retains authentication ownership, Admin and Organization retain their membership records, and resource-owning services retain ownership and lifecycle checks. The examples below describe intended policy behavior rather than a shared package API or implemented enforcement.

Rationale ​

Why PBAC? ​

Our requirements need conditions beyond simple roles:

  • "Doctor can create prescriptions only if subscription is active"
  • "Prescription drafts are author-private; issued/canceled history is shared with authorized organization members"
  • "Only the authoring doctor can edit, issue or cancel a prescription"
  • "Org admin can manage members in their organization"

PBAC provides flexibility without the complexity of full ABAC.

Policy Structure ​

go
type Policy struct {
    Name        string
    Resource    Resource  // "prescription", "member", "organization"
    Action      Action    // "create", "read", "update", "delete"
    Effect      Effect    // "allow", "deny"
    Conditions  []Condition
}

Example Policies ​

json
{
    "name": "doctor_create_prescription",
    "resource": "prescription",
    "action": "create",
    "effect": "allow",
    "conditions": [
        "has_role:doctor",
        "same_organization",
        "org_not_suspended",
        "subscription_active"
    ]
}
json
{
    "name": "doctor_view_own_drafts",
    "resource": "prescription",
    "action": "read",
    "effect": "allow",
    "conditions": [
        "has_role:doctor",
        "is_resource_owner",
        "same_organization",
        "active_doctor_affiliation",
        "status:draft"
    ]
}

The draft-read example above describes the intended ADR-015 policy. Shared reads of issued/canceled prescriptions require active identity, matching organization context and authorized membership/affiliation; ownership is not required. Example condition names are policy design, not a claim that these conditions are implemented.

Condition Functions ​

go
type Condition func(ctx context.Context, pc PolicyContext) bool

// Built-in conditions
HasRole(role Role) Condition
HasAnyRole(roles ...Role) Condition
IsResourceOwner() Condition
IsSameOrganization() Condition
IsSuperAdmin() Condition
OrganizationNotSuspended() Condition
SubscriptionActive() Condition

// Composable
All(conditions ...Condition) Condition  // AND
Any(conditions ...Condition) Condition  // OR

Policy Context ​

go
type PolicyContext struct {
    UserID                   string
    Roles                    []Role
    OrganizationID           string
    Resource                 Resource
    Action                   Action
    ResourceOwnerID          string
    ResourceOrganizationID   string
    Extra                    map[string]any
}

Evaluation Logic ​

  1. Deny policies take precedence over allow
  2. All conditions in a policy must be true (AND)
  3. If no policy matches, access is denied
go
func (e *Engine) Evaluate(ctx context.Context, pc PolicyContext) bool {
    hasAllow := false
    for _, policy := range e.policies {
        if !policyMatches(policy, pc) { continue }
        if !conditionsMet(policy.Conditions, ctx, pc) { continue }
        if policy.Effect == EffectDeny { return false }
        if policy.Effect == EffectAllow { hasAllow = true }
    }
    return hasAllow
}

Subscription and Organization Actor Context ​

Platform admin/super_admin manage billing; organization admins only read their organization's billing and manage its ordinary members and doctor onboarding. These principals are distinct. Effective subscription access uses explicit status and applicable dates; a payment does not activate/extend access and an unpaid invoice does not automatically change it (ADR-014). Active ordinary member counts and active doctor-affiliation counts are separate limit inputs. The new documentation-first routes declare these policies but return 501 without authorization or entitlement enforcement. Prescription eligibility examples above remain future policy design, not an implemented prescription gate.

Patient and Prescription Actor Context ​

Patient profile creation/editing and patient search are intended for authorized active organization doctors, admins and staff. Prescription creation derives the organization from an authorized patient lookup and the author from authentication. Only the author may edit, issue or cancel a prescription; active doctor affiliation is required. Drafts remain author-private, while issued/canceled history is shared within the authorized organization. Cross-organization identifiers are hidden with 404; known forbidden actions return 403 once enforcement exists.

These operations currently return 501 without enforcing identity, membership, affiliation or ownership. The policy examples in this ADR are not wired into these contracts and do not establish their runtime access behavior. Version and lifecycle checks are also deferred. Subscription/verification eligibility gates remain outside this release; see ADR-015 for the accepted contract behavior.

Consequences ​

Positive ​

  • Fine-grained access control
  • Policies are data (can be stored in DB)
  • Easy to add new policies
  • Composable conditions

Negative ​

  • More complex than simple RBAC
  • Need to pass context for evaluation

Documentation follows the source. Planned contracts are marked explicitly.