ADR-0015: Bitwise Permission System — Fine-Grained Method Authorization¶
Metadata¶
Status: Accepted · Date: 2026-06-02 · Deciders: nguyenhuuca · Tags: security, authorization, spring-security, feature-flag Related PRD: N/A · Related Spec: spec-bitwise-permissions · Related Plan: plan-permissions-bitwise Supersedes: N/A · Superseded By: N/A
Tech Strategy: ✅ Follows Golden Path — Spring Security + JWT, PostgreSQL, no new infrastructure beyond the Harness FF SDK
Documented retroactively (AS-BUILT) for tracking. The system is implemented and merged; this ADR records the decisions actually made, which diverge from the original plan in two places (noted inline).
Context¶
The app guards admin endpoints with a single coarse role check (@PreAuthorize("hasRole('ADMIN')")). This is all-or-nothing: any feature that needs a capability finer than "is admin" has nowhere to express it. We need a fine-grained, composable permission layer that can sit alongside the existing role guard without breaking it, and that can be rolled out safely (off by default).
A partially-built permission system already existed (Permission enum, PermissionService, @HasPermission, a seed migration) but was non-functional end-to-end: bit values were misaligned, the User entity had no permissions field, there was no custom UserDetails to expose permissions to SpEL, and the annotation's SpEL expression called the wrong method signature.
This ADR captures the architectural decisions taken to wire it together.
Decision Drivers¶
- Permission checks must be expressible at the method level, declaratively
- Must coexist with the existing class-level
hasRole('ADMIN')guard — additive, not a replacement - Must be rolled out safely: default behavior unchanged until explicitly enabled
- Permissions must be cheap to store and check (high-frequency, on every guarded request)
- Schema must stay clean; minimize new infrastructure
Decision 1: Bitmask stored as int (not long)¶
Permissions are modeled as a bitmask: each Permission enum constant owns one bit (1 << n), a user's granted set is a single integer column, and a check is a bitwise AND.
| Option | Pros | Cons |
|---|---|---|
int (32 bits) ✅ |
32 capabilities — far more than needed; PostgreSQL INT; no L noise in enum |
Caps at 32 permissions (acceptable) |
long (64 bits) |
64 capabilities | BIGINT column, 1L << n noise, over-provisioned |
Join table (user_permissions) |
Unbounded, relational | N+1 reads, extra joins on every guarded request — over-engineered for a small fixed set |
Outcome: int. 32 bits is comfortably more than this app will use, a check is a single & with zero allocations, and storage is one INT column. A join table is the right answer only if permissions become dynamic/unbounded — explicitly not the case here.
Decision 2: Custom UserDetails (AppUserDetails) to expose permissions to SpEL¶
Spring's stock org.springframework.security.core.userdetails.User has no permissions property, so authentication.principal.permissions cannot resolve in a @PreAuthorize expression.
| Option | Pros | Cons |
|---|---|---|
Custom AppUserDetails wrapping User ✅ |
principal.permissions resolves directly in SpEL; carries id too |
One small new class |
Encode permissions as GrantedAuthority strings |
No new class | Stringly-typed, loses the bitmask, awkward bitwise checks |
| Re-query the DB inside the check | Always fresh | DB hit on every guarded request — defeats the point |
Outcome: AppUserDetails implements UserDetails, wraps the User entity, exposes int getPermissions() and Long getId(). UserServiceImpl.loadUserByUsername() returns it, so the bitmask travels with the authenticated principal and is readable from SpEL with no extra query.
Decision 3: Single-permission annotation via template expression + custom PropertyAccessor¶
Divergence from plan. The plan proposed
@HasPermission(perms = {…})(an array) wired tohasAllPermissions(...). The as-built implementation uses a singlePermission perm()and a cleaner SpEL mechanism. The array methods (hasAllPermissions/hasAnyPermission) remain on the service for future multi-permission needs but are not used by the annotation.
The annotation is:
@PreAuthorize("@permissionServiceImpl.hasPermission(authentication.principal.permissions, {perm})")
public @interface HasPermission { Permission perm(); }
Two Spring Security beans make this work (in WebSecurityConfig):
AnnotationTemplateExpressionDefaults— expands the{perm}template placeholder, turning@HasPermission(perm = Permission.ADMIN)into the bare identifierADMINinside the SpEL expression.PermissionEnumPropertyAccessor(registered on a customMethodSecurityExpressionHandler) — resolves that bare identifierADMINback to thePermission.ADMINconstant in the evaluation context, since SpEL cannot otherwise resolve a bare enum name.
| Option | Pros | Cons |
|---|---|---|
Single perm + template + property accessor ✅ |
Clean call site @HasPermission(perm = Permission.ADMIN); type-safe enum arg; no array ceremony |
Requires two security beans |
Array perms + hasAllPermissions (plan) |
Multiple perms in one annotation | Array SpEL plumbing, AND-vs-OR ambiguity at call site |
Pass raw int bit to annotation |
No accessor needed | Magic numbers at call site, loses enum safety |
Outcome: single-permission annotation. It covers every current use case (one capability per endpoint), reads cleanly, and keeps the enum type-safe at the call site. Multi-permission AND/OR semantics are available on the service if a future endpoint needs them.
Decision 4: Feature-flag gate, permissive by default (Harness FF)¶
Enforcement is gated by the boolean Harness flag permission_enforcement_enabled.
| Flag | Behavior |
|---|---|
| OFF (default) | All three PermissionService methods short-circuit to true — only the existing role guard applies |
| ON | Bitwise check enforced |
| Option | Pros | Cons |
|---|---|---|
| Feature-flag gated, default OFF ✅ | Safe rollout; existing hasRole('ADMIN') still protects everything; if Harness is unreachable, false keeps the app working |
Adds the ff-java-server-sdk dependency |
| Always-on | No flag plumbing | Risky big-bang; a seed/bit mistake locks out admins |
Config property (application.yaml) |
No SDK | No runtime toggle, no per-target rollout |
Outcome: Harness FF, default false. The FeatureFlagService abstraction is a no-op when the API key is blank, so local/test runs need no flag backend. This makes the new layer strictly additive: with the flag off, nothing changes.
Decision Outcome Summary¶
| # | Decision | Choice |
|---|---|---|
| D1 | Bitmask width | int (32 bits), PostgreSQL INT |
| D2 | Principal carries permissions | Custom AppUserDetails wrapping User |
| D3 | Annotation shape | Single @HasPermission(perm = X) via template expression + PermissionEnumPropertyAccessor |
| D4 | Rollout | Harness FF gate, default OFF (permissive) |
Consequences¶
Positive:
- Method-level, declarative, type-safe authorization: @HasPermission(perm = Permission.ADMIN)
- Strictly additive — role guard untouched, flag-off = no behavior change
- O(1), allocation-free permission check (single bitwise AND)
- Permission set travels with the JWT principal; no extra DB read per check
Negative:
- Capped at 32 permissions (acceptable headroom)
- Two non-obvious Spring Security beans required to make the SpEL resolve — documented in WebSecurityConfig and HasPermission comments
- New runtime dependency: io.harness:ff-java-server-sdk
Risks:
- Misaligned bit_value seed vs. Permission enum grants the wrong capabilities → mitigated: seed code/bit_value match the enum exactly; verified by acceptance criteria
- Flag on with empty user permission bitmask (default 0) locks users out of newly-guarded endpoints → mitigated: default OFF; permissions must be seeded before enabling
Schema Reference¶
-- Migration: db/changelog/sql/202605010001-init_permissions.sql
CREATE TABLE permissions (
id INT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE, -- matches Permission enum name
name VARCHAR(100) NOT NULL,
bit_value INT NOT NULL UNIQUE, -- matches Permission.getBit()
description TEXT,
group_name VARCHAR(50),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO permissions (id, code, name, bit_value, description, group_name) VALUES
(1, 'READ', 'Read', 1, 'Read data', 'GENERAL'),
(2, 'WRITE', 'Write', 2, 'Modify data', 'GENERAL'),
(3, 'EXEC', 'Exec', 4, 'Execute actions', 'GENERAL'),
(4, 'DELETE', 'Delete', 8, 'Delete data', 'GENERAL'),
(5, 'ADMIN', 'Admin', 16, 'Full admin access', 'ADMIN');
ALTER TABLE users ADD COLUMN permissions INT NOT NULL DEFAULT 0;
Validation¶
- [x] SQL seed
bit_valuematchesPermissionenum (READ=1, WRITE=2, EXEC=4, DELETE=8, ADMIN=16) - [x]
Permissionenum usesintwith1 << n - [x]
AppUserDetailsexposesint getPermissions();loadUserByUsername()returns it - [x]
authentication.principal.permissionsresolves in SpEL;Permission.ADMIN→ADMINresolves viaPermissionEnumPropertyAccessor - [x]
@HasPermission(perm = Permission.ADMIN)enforces access onAdminController.getStats() - [x] Flag OFF → all checks return
true; existinghasRole('ADMIN')guard unaffected - [x] Tech Strategy alignment: ✅ no new infrastructure beyond FF SDK
Links¶
- Spec: Bitwise Permissions
- Plan: Bitwise Permission System
- ADR-0002: Backend Architecture — Spring Security + JWT baseline
Changelog¶
| Date | Author | Change |
|---|---|---|
| 2026-06-02 | nguyenhuuca | Initial AS-BUILT draft (retroactive, for tracking) |