Phase 9: IAM Hardening
Depends on: All previous phases (1-8) New Endpoints: 0 (behavior change only) New Files: 1
Goal
Replace the allow-all IAM stub with a real permission enforcement engine based on the Dafny-verified IAM specification. After this phase, every API endpoint enforces fine-grained access control based on the user's roles, groups, and permission modifiers.
Current State
The IAM system is currently an allow-all stub:
// iam/IamService.java (current)
public boolean checkAccess(UUID userId, String service, String action) {
return true; // Allow all - stub
}
The Dafny specification at apps/specifications/dafny/iam.dfy defines the complete IAM model but is not yet finalized for enforcement.
Dafny IAM Model Summary
From iam.dfy (564 lines):
Core Types
| Type | Description |
|---|---|
Service | An API endpoint or microservice |
Permission | Allow or Deny access to a Service |
Group | System-defined or user-defined collection of Permissions |
Role | Organization-scoped aggregation of Groups |
User | Has Roles + direct Groups + Permission Modifiers |
Organization | Scopes Roles |
Permission Modifiers
| Modifier | Effect |
|---|---|
GrantAdditional | Adds a permission beyond what Roles provide |
Revoke | Removes a specific permission from the effective set |
Override | Forces allow/deny regardless of Role-based permissions |
Key Invariants (Dafny-verified)
- Users MUST have at least one Role
- Roles are Organization-scoped — a role in Org A doesn't grant access in Org B
- Adding a Role preserves existing access — only additive
- Deny overrides Allow at the same level (unless Override modifier)
- Override modifier takes precedence over Role-based permissions
Verified Operations
CreateUser— must assign at least one RoleAddRoleToUser— preserves existing permissionsAddModifierToUser— applies permission modifierCreateSystemGroup— creates a system-level permission groupCreateRole— creates an org-scoped role from groups
Implementation Plan
Step 1: Define Permission Schema
Map each API endpoint to a Service + Action pair:
auth.login -> public (no check)
auth.me -> authenticated (JWT valid)
org.members.list -> org.members:read
org.members.add -> org.members:write
store.products.list -> store.products:read
store.products.create -> store.products:write
store.products.delete -> store.products:admin
...
Step 2: Define Default Roles
| Role | Groups (Permission Sets) |
|---|---|
org_owner | All org permissions + all store permissions |
org_admin | All org permissions + all store permissions (except owner-only) |
org_member | Org read permissions only |
store_admin | All store permissions |
manager | Store read + write (no admin actions like delete, terminal mgmt) |
cashier | Store read + transaction creation |
stocker | Store read + inventory management |
Step 3: Implement IamDecisionEngine
// iam/IamDecisionEngine.java
public class IamDecisionEngine {
/**
* Evaluate access for a user against a service:action pair.
*
* Resolution order:
* 1. Check Override modifiers (highest priority)
* 2. Check Revoke modifiers
* 3. Check GrantAdditional modifiers
* 4. Check Role-based permissions (aggregate from all Roles)
* 5. Default: DENY
*/
public boolean evaluate(
UUID userId,
UUID orgId,
UUID storeId, // nullable for org-level checks
String service,
String action
) { ... }
}
Step 4: Update IamService
Replace the allow-all stub:
// iam/IamService.java (updated)
public boolean checkAccess(AuthenticatedUser user, String service, String action) {
return iamDecisionEngine.evaluate(
user.userId(),
OrgContext.getCurrentOrgId(),
StoreContext.getCurrentStoreId(),
service,
action
);
}
Step 5: Add IAM Checks to Controllers
Two options (choose one):
Option A: Annotation-based
@IamCheck(service = "store.products", action = "write")
@PostMapping
public ApiResponse<ProductResponse> create(...) { ... }
Option B: Programmatic (current pattern)
@PostMapping
public ApiResponse<ProductResponse> create(...) {
iamService.checkAccess(authenticatedUser, "store.products", "write");
// ... existing logic
}
Step 6: Seed Default Roles/Groups
Create a data migration or startup initializer that seeds:
- System groups (one per service:action pair)
- Default roles (org_owner, org_admin, org_member, store_admin, manager, cashier, stocker)
- Links between roles and groups
Database Requirements
May need new tables (or extend existing schema):
-- IAM tables (if not already in SQLDelight schema)
CREATE TABLE iam_groups (
group_id UUID PRIMARY KEY,
name TEXT NOT NULL,
group_type TEXT NOT NULL, -- 'system' or 'user_defined'
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE iam_group_permissions (
group_id UUID REFERENCES iam_groups,
service TEXT NOT NULL,
action TEXT NOT NULL,
effect TEXT NOT NULL, -- 'allow' or 'deny'
PRIMARY KEY (group_id, service, action)
);
CREATE TABLE iam_roles (
role_id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE iam_role_groups (
role_id UUID REFERENCES iam_roles,
group_id UUID REFERENCES iam_groups,
PRIMARY KEY (role_id, group_id)
);
CREATE TABLE iam_user_modifiers (
modifier_id UUID PRIMARY KEY,
user_id UUID REFERENCES users,
org_id UUID REFERENCES organizations,
modifier_type TEXT NOT NULL, -- 'grant_additional', 'revoke', 'override'
service TEXT NOT NULL,
action TEXT NOT NULL,
effect TEXT NOT NULL, -- 'allow' or 'deny'
created_at TIMESTAMPTZ DEFAULT now()
);
Migration Strategy
- Phase 9a: Add IAM tables to SQLDelight schema, implement IamDecisionEngine, seed default roles
- Phase 9b: Wire IamService to IamDecisionEngine, add checks to all controllers
- Phase 9c: Add admin endpoints for custom role/group management (management-api, not merchant-api)
- Rollback plan: Feature flag
iam.enforcement.enabled=falsefalls back to allow-all
Risk & Dependencies
- Dafny spec must be finalized before implementing the decision engine
- Existing tests will need updates — mock IamService to return specific allow/deny per test
- Performance: IAM checks on every request — consider caching user permissions
- Management-api integration: Role/group CRUD lives in management-api, not merchant-api
- Data migration: Existing users need default roles assigned
Acceptance Criteria
- IamDecisionEngine correctly evaluates Override > Revoke > GrantAdditional > Role
- Deny takes precedence over Allow at the same level
- Users without the required permission receive 403 Forbidden
- Public and authenticated-only endpoints remain unaffected
- Default roles provide the same access as the current role-based middleware
- Feature flag allows disabling enforcement (fallback to allow-all)
- All existing tests pass with mocked IAM
- Performance: IAM check adds < 5ms per request
- Bazel build passes
- Integration tests verify permission boundaries for each role