Feature Specification: Bitwise Permission System¶
Metadata¶
Status: Implemented Author: nguyenhuuca Date: 2026-06-02 Related PRD: N/A Related ADR: ADR-0015: Bitwise Permission System Related Plan: plan-permissions-bitwise
Documented retroactively (AS-BUILT) for tracking. Describes the behavior actually shipped.
Overview¶
The system provides fine-grained, method-level authorization on top of the existing role guard. Each capability is a single bit in a 32-bit mask; a user's granted capabilities are stored as one int column. Endpoints declare a required capability with @HasPermission(perm = Permission.X), and access is granted only when the user's bitmask contains that bit. Enforcement is gated by a feature flag and is permissive (no-op) by default.
Business Rules¶
Rule 1¶
A Permission is one bit: READ=1, WRITE=2, EXEC=4, DELETE=8, ADMIN=16 (1 << n). The permissions table seed (code, bit_value) must match the Permission enum exactly.
Rule 2¶
A user's granted permissions are a single int bitmask stored in users.permissions, defaulting to 0 (no permissions).
Rule 3¶
A single-permission check (hasPermission) grants access when (userBits & perm.bit) != 0.
Rule 4¶
When the feature flag permission_enforcement_enabled is OFF (the default), all permission checks return true — only the class-level role guard (hasRole('ADMIN')) applies. The permission layer is additive and never weakens an existing guard.
Rule 5¶
If the Harness FF backend is unreachable or the API key is blank, the flag resolves to its default (false) — the application continues to function with role-only authorization.
Functional Requirements¶
FR-1: Declarative method guard¶
The system must provide a @HasPermission(perm = Permission.X) annotation usable on a controller method or type. It is a meta-annotation over @PreAuthorize that calls permissionServiceImpl.hasPermission(principal.permissions, perm).
FR-2: Permission resolution in SpEL¶
authentication.principal.permissions must resolve to the user's int bitmask, and the bare enum identifier (e.g. ADMIN) in the expanded template expression must resolve to the corresponding Permission constant.
FR-3: Single-permission check¶
PermissionService.hasPermission(int userBits, Permission perm) must return true iff the flag is enforced AND (userBits & perm.getBit()) != 0. When the flag is off, it returns true unconditionally.
FR-4: Multi-permission checks (available, not yet wired to annotation)¶
hasAllPermissions(int, Permission[]) (AND) and hasAnyPermission(int, Permission[]) (OR) must be available on PermissionService for future use. Both short-circuit to true when the flag is off.
FR-5: Principal carries permissions¶
UserServiceImpl.loadUserByUsername() must return an AppUserDetails that wraps the User entity and exposes int getPermissions() and Long getId().
FR-6: Feature-flag gate¶
FeatureFlagService.isEnabled(String flag, boolean defaultValue) gates enforcement. PermissionServiceImpl must consult permission_enforcement_enabled (default false) before performing any bitwise check.
FR-7: Demo enforcement¶
GET /admin/stats must be annotated @HasPermission(perm = Permission.ADMIN) to validate the path end-to-end.
Components¶
Backend¶
| Component | File | Role |
|---|---|---|
Permission enum |
enums/Permission.java |
Source of truth — bit per capability (int, 1 << n) |
@HasPermission |
aop/HasPermission.java |
Meta-@PreAuthorize; Permission perm() |
PermissionService / Impl |
service/PermissionService.java, service/impl/PermissionServiceImpl.java |
hasPermission / hasAllPermissions / hasAnyPermission; flag short-circuit |
AppUserDetails |
config/AppUserDetails.java |
Custom UserDetails exposing getPermissions() / getId() |
WebSecurityConfig |
filter/WebSecurityConfig.java |
AnnotationTemplateExpressionDefaults + PermissionEnumPropertyAccessor on a custom MethodSecurityExpressionHandler; @EnableMethodSecurity |
FeatureFlagService / Impl |
service/FeatureFlagService.java, service/impl/FeatureFlagServiceImpl.java |
Harness FF wrapper; no-op when key blank |
AppConstant.Flags |
utils/AppConstant.java |
PERMISSION_ENFORCEMENT flag key |
Annotation contract¶
Expands (via AnnotationTemplateExpressionDefaults) to:
PermissionEnumPropertyAccessor.read(...) maps the bare ADMIN → Permission.ADMIN so SpEL can pass it to hasPermission.
API Changes¶
Modified Endpoint¶
GET /admin/stats¶
Change: Added @HasPermission(perm = Permission.ADMIN) (in addition to the existing class-level hasRole('ADMIN') guard).
Behavior:
| Flag | User bitmask has ADMIN bit (16)? | Result |
|---|---|---|
| OFF (default) | — | Allowed (role guard only) |
| ON | Yes | Allowed |
| ON | No | 403 Forbidden (AccessDeniedException) |
No request/response body change. No new public endpoints introduced by this feature.
Database Changes¶
-- Migration: db/changelog/sql/202605010001-init_permissions.sql
CREATE TABLE permissions (
id INT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
bit_value INT NOT NULL UNIQUE,
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;
users.permissions is an additive INT column; existing rows default to 0.
Configuration¶
| Key | Source | Default | Purpose |
|---|---|---|---|
harness.ff.api-key |
application.yaml ← ${FF_API_KEY:} |
empty | Harness FF SDK key; blank ⇒ FeatureFlagService is a no-op |
permission_enforcement_enabled |
Harness FF | false |
Master switch for bitwise enforcement |
New dependency: io.harness:ff-java-server-sdk.
Security Requirements¶
- Additive only: The permission layer must never weaken the existing class-level
hasRole('ADMIN')guard. With the flag off, authorization behavior is identical to before. - Principal source: Permission bits come from the authenticated
AppUserDetails, populated atloadUserByUsername()— never from request input. - Fail-safe default: Unreachable flag backend ⇒
false⇒ role-only authorization (no lockout, no silent allow of newly-guarded endpoints since enforcement simply stays off). - No secrets in code:
FF_API_KEYis supplied via environment, not hardcoded.
Edge Cases¶
EC-1: User with permissions = 0, flag ON, accesses @HasPermission(perm = ADMIN) endpoint¶
(0 & 16) == 0 → denied → 403. (Role guard may still allow if it is the only annotation; here both apply, the stricter wins.)
EC-2: Flag OFF¶
hasPermission returns true immediately; bitwise check never runs. Logged as "permission check skipped (flag off)".
EC-3: Harness key blank (local/test)¶
FeatureFlagServiceImpl is a no-op and returns the supplied default (false). App runs without a flag backend.
EC-4: Bare enum name not a valid Permission¶
PermissionEnumPropertyAccessor.canRead returns false (catches IllegalArgumentException) → SpEL falls through; only valid Permission names resolve.
EC-5: Multiple bits set (e.g. user has READ|WRITE|ADMIN = 19)¶
19 & 16 = 16 ≠ 0 → ADMIN check passes; unrelated bits do not interfere.
Acceptance Criteria¶
- [x] SQL seed
bit_valuematchesPermissionenum exactly - [x]
Permissionenum usesintwith1 << n - [x]
PermissionService/Impluseintfor all permission params - [x]
User.javahas anint permissionsfield mapped to thepermissionscolumn - [x]
AppUserDetailswrapsUserand exposesint getPermissions() - [x]
loadUserByUsername()returnsAppUserDetails - [x]
authentication.principal.permissionsresolves in SpEL - [x]
Permission.ADMINresolves to the enum constant viaPermissionEnumPropertyAccessor - [x]
@HasPermission(perm = Permission.ADMIN)enforces access ongetStats()when flag ON - [x] Flag OFF → all checks return
true; existing role guard unaffected - [x] FF backend unreachable / key blank → defaults to
false, app functions - [x]
./unittest.sh/mvn verifypasses
Open Questions¶
- [ ] Resolved:
intvslong→int(32 bits, ADR-0015 D1) - [ ] Resolved: array vs single perm on annotation → single
perm(ADR-0015 D3); array service methods retained for future - [ ] Future: wire
hasAllPermissions/hasAnyPermissionto a multi-permission annotation variant if an endpoint needs AND/OR semantics
Version History¶
| Version | Date | Author | Change |
|---|---|---|---|
| 1.0 | 2026-06-02 | nguyenhuuca | Initial AS-BUILT draft (retroactive, for tracking) |