Skip to main content

ADR-007: Field-Level RBAC/ABAC with Visibility Tags

Status: Accepted Date: February 2026

Context

Enterprise coaching creates sensitive data that different stakeholders should see at different levels. A simple role-based system (Coach sees everything, Client sees nothing) is too coarse. We need fine-grained control over which specific pieces of data are visible to which roles.

Additionally, executives and HR leaders want aggregate program metrics but should never see individual coaching content. This requires architectural enforcement, not just policy.

Decision

Implement a hybrid RBAC/ABAC system using four field-level visibility tags that control data access at the record and field level.

Visibility Tags

TagVisible ToExamples
client_visibleCoachee + Coach + (their) Admin (content only)Approved session summaries, action items, shared evidence packs
coach_onlyCoach only (not client, not admin, not exec)Private coaching notes, draft insights, raw AI outputs before approval

| admin_aggregate | Enterprise Admin + Executive (anonymized aggregates only) | "80% improved leadership scores", "Average 5 sessions per person" | | system_internal | System only (no human sees directly) | Raw embeddings, vector indices, AI intermediate processing, prompt logs |

RBAC Layer (Who)

Six roles with fixed permission sets:

  • Coachee: Read own client_visible data
  • Coach: Read/write client_visible + coach_only for their assigned coachees
  • Admin: Read admin_aggregate for their org; manage users; cannot see session content
  • Executive: Read admin_aggregate (further restricted to org-level only)
  • Sasha (AI): Read data within active user's permission scope; write derived insights
  • System Admin (internal): Access for support with full audit logging

ABAC Layer (What)

Visibility tags are attributes on data records. The system checks both role AND tag:

canAccess(user, record) =
user.tenant_id == record.tenant_id // Tenant isolation
AND user.role permits record.visibility_tag // Tag check
AND (record.assigned_to includes user OR user.role == admin with aggregate only)

Alternatives Considered

Alternative 1: Simple RBAC (Role-Only)

  • Pro: Simple to implement, well-understood pattern
  • Con: No field-level control; either Coach sees everything or nothing; Admin either sees content or nothing
  • Rejected because: The coaching context requires nuanced visibility (coach-private notes must stay private even from admin)

Alternative 2: Full ABAC (No Fixed Roles)

  • Pro: Maximum flexibility; every permission is a policy rule
  • Con: Complex to implement, hard to audit, difficult to explain to enterprise security teams
  • Rejected because: Over-engineered; our domain has clear, fixed roles

Alternative 3: Encryption-Based Access Control

  • Pro: Cryptographically enforced; even database compromise can't expose wrong data
  • Con: Key management nightmare; every access requires decryption; can't do database queries on encrypted fields easily
  • Rejected because: Performance and operational complexity too high for startup stage

Consequences

Positive

  • Coaches can maintain private notes with confidence they won't leak to HR
  • Admin/exec views are architecturally limited to aggregates (not just a UI choice)
  • GDPR data minimization is enforced by design (people only see what they need)
  • Enterprise security teams love this level of access control specificity
  • Evidence pack visibility can be selectively controlled per item

Negative

  • Every query must include visibility tag filtering (additional WHERE clause complexity)
  • Promoting visibility (e.g., coach approving insight → changes from coach_only to client_visible) requires explicit state change with audit logging
  • Aggregate threshold enforcement (min 5 users) requires additional logic
  • Sasha's context assembly must respect tags (performance consideration)

Implementation

Database Implementation

-- Every content table includes these columns
CREATE TABLE insights (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
session_id UUID NOT NULL REFERENCES sessions(id),
visibility_tag VARCHAR(20) NOT NULL DEFAULT 'coach_only'
CHECK (visibility_tag IN ('client_visible', 'coach_only', 'admin_aggregate', 'system_internal')),
-- ... other fields
);

-- Row-Level Security policy
CREATE POLICY tenant_isolation ON insights
USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Application-level visibility check (in API middleware)
-- SELECT * FROM insights
-- WHERE tenant_id = $tenant_id
-- AND visibility_tag = ANY($allowed_tags_for_role)
-- AND (session_id IN (SELECT id FROM sessions WHERE coach_id = $user_id)
-- OR $user_role = 'admin' AND visibility_tag = 'admin_aggregate');

References