Skip to main content

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

TypeDescription
ServiceAn API endpoint or microservice
PermissionAllow or Deny access to a Service
GroupSystem-defined or user-defined collection of Permissions
RoleOrganization-scoped aggregation of Groups
UserHas Roles + direct Groups + Permission Modifiers
OrganizationScopes Roles

Permission Modifiers

ModifierEffect
GrantAdditionalAdds a permission beyond what Roles provide
RevokeRemoves a specific permission from the effective set
OverrideForces allow/deny regardless of Role-based permissions

Key Invariants (Dafny-verified)

  1. Users MUST have at least one Role
  2. Roles are Organization-scoped — a role in Org A doesn't grant access in Org B
  3. Adding a Role preserves existing access — only additive
  4. Deny overrides Allow at the same level (unless Override modifier)
  5. Override modifier takes precedence over Role-based permissions

Verified Operations

  • CreateUser — must assign at least one Role
  • AddRoleToUser — preserves existing permissions
  • AddModifierToUser — applies permission modifier
  • CreateSystemGroup — creates a system-level permission group
  • CreateRole — 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

RoleGroups (Permission Sets)
org_ownerAll org permissions + all store permissions
org_adminAll org permissions + all store permissions (except owner-only)
org_memberOrg read permissions only
store_adminAll store permissions
managerStore read + write (no admin actions like delete, terminal mgmt)
cashierStore read + transaction creation
stockerStore 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

  1. Phase 9a: Add IAM tables to SQLDelight schema, implement IamDecisionEngine, seed default roles
  2. Phase 9b: Wire IamService to IamDecisionEngine, add checks to all controllers
  3. Phase 9c: Add admin endpoints for custom role/group management (management-api, not merchant-api)
  4. Rollback plan: Feature flag iam.enforcement.enabled=false falls 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

  1. IamDecisionEngine correctly evaluates Override > Revoke > GrantAdditional > Role
  2. Deny takes precedence over Allow at the same level
  3. Users without the required permission receive 403 Forbidden
  4. Public and authenticated-only endpoints remain unaffected
  5. Default roles provide the same access as the current role-based middleware
  6. Feature flag allows disabling enforcement (fallback to allow-all)
  7. All existing tests pass with mocked IAM
  8. Performance: IAM check adds < 5ms per request
  9. Bazel build passes
  10. Integration tests verify permission boundaries for each role