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
type Policy struct {
Name string
Resource Resource // "prescription", "member", "organization"
Action Action // "create", "read", "update", "delete"
Effect Effect // "allow", "deny"
Conditions []Condition
}Example Policies
{
"name": "doctor_create_prescription",
"resource": "prescription",
"action": "create",
"effect": "allow",
"conditions": [
"has_role:doctor",
"same_organization",
"org_not_suspended",
"subscription_active"
]
}{
"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
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 // ORPolicy Context
type PolicyContext struct {
UserID string
Roles []Role
OrganizationID string
Resource Resource
Action Action
ResourceOwnerID string
ResourceOrganizationID string
Extra map[string]any
}Evaluation Logic
- Deny policies take precedence over allow
- All conditions in a policy must be true (AND)
- If no policy matches, access is denied
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
Related Decisions
ADR-001: Modular Monolith Architecture