--- phase: 06-backend-authentication plan: 03 type: execute wave: 2 depends_on: ["06-01"] files_modified: - summercms/src/auth/Permission.scala - summercms/src/auth/PermissionRegistry.scala - summercms/src/auth/PermissionChecker.scala - summercms/src/auth/TotpService.scala - summercms/src/auth/middleware/PermissionMiddleware.scala - summercms/src/auth/routes/TotpRoutes.scala - summercms/src/auth/routes/RoleRoutes.scala - summercms/src/auth/repository/RoleRepository.scala - summercms/src/auth/LoginService.scala - summercms/src/api/Routes.scala - build.mill autonomous: true must_haves: truths: - "Roles can be created with specific permissions attached" - "Controllers and views check permissions before rendering protected content" - "Wildcard permissions work (e.g., 'blog.*' matches 'blog.posts.create')" - "Super Admin role has '*' permission that matches everything" - "2FA can be enabled optionally by admin users" artifacts: - path: "summercms/src/auth/PermissionRegistry.scala" provides: "Plugin permission registration" exports: ["PermissionRegistry", "register", "getAllPermissions"] - path: "summercms/src/auth/PermissionChecker.scala" provides: "Permission checking with wildcard support" exports: ["PermissionChecker", "hasPermission", "hasAnyPermission"] - path: "summercms/src/auth/middleware/PermissionMiddleware.scala" provides: "Route-level permission enforcement" exports: ["PermissionMiddleware", "require"] - path: "summercms/src/auth/TotpService.scala" provides: "TOTP 2FA setup and verification" exports: ["TotpService", "generateSecret", "verifyCode"] key_links: - from: "PermissionMiddleware" to: "PermissionChecker" via: "permission validation" pattern: "permissionChecker\\.hasPermission" - from: "PermissionChecker" to: "RoleRepository" via: "role lookup" pattern: "roleRepository\\.findById" - from: "LoginService" to: "TotpService" via: "2FA verification" pattern: "totpService\\.verifyCode" --- Implement role-based access control (RBAC) with plugin-registered permissions and optional TOTP 2FA. Purpose: Enable fine-grained access control where plugins can register their own permissions, roles aggregate permissions, and middleware enforces access rules. Optional 2FA adds an extra security layer for admin accounts. Output: - Permission registry for plugin-registered permissions - Permission checker with wildcard matching - Permission middleware for route protection - Role CRUD endpoints - TOTP 2FA setup and verification @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/06-backend-authentication/06-CONTEXT.md @.planning/phases/06-backend-authentication/06-RESEARCH.md @.planning/phases/06-backend-authentication/06-01-SUMMARY.md @.planning/phases/02-plugin-system/02-RESEARCH.md # Dependencies from 06-01 @summercms/src/auth/Role.scala @summercms/src/auth/AdminUser.scala @summercms/src/auth/LoginService.scala @summercms/src/auth/middleware/AuthMiddleware.scala Task 1: Create permission system summercms/src/auth/Permission.scala summercms/src/auth/PermissionRegistry.scala summercms/src/auth/PermissionChecker.scala summercms/src/auth/repository/RoleRepository.scala 1. Create Permission.scala: - case class PermissionDef( code: String, // e.g., "golem15.blog.access_posts" label: String, // Human-readable: "Access blog posts" tab: String, // Grouping in UI: "Blog" order: Int = 0 ) - companion object with helper methods 2. Create PermissionRegistry (ZIO service with Ref): - trait PermissionRegistry: def register(pluginId: String, permissions: List[PermissionDef]): UIO[Unit] def getAllPermissions: UIO[List[PermissionDef]] def getPermissionsForPlugin(pluginId: String): UIO[List[PermissionDef]] def getPermissionsByTab: UIO[Map[String, List[PermissionDef]]] - Implementation uses ZIO Ref[Map[String, List[PermissionDef]]] - Register some core permissions on initialization: - "backend.access" - "Access admin backend" - "backend.manage_users" - "Manage admin users" - "backend.manage_roles" - "Manage roles" 3. Create RoleRepository (following existing patterns): - findById(id: Long): IO[RepositoryError, Option[Role]] - findByCode(code: String): IO[RepositoryError, Option[Role]] - findAll: IO[RepositoryError, List[Role]] - create(role: Role): IO[RepositoryError, Role] - update(role: Role): IO[RepositoryError, Role] - delete(id: Long): IO[RepositoryError, Unit] Use Quill with querySchema mapping. 4. Create PermissionChecker (ZIO service): - trait PermissionChecker: def hasPermission(userId: Long, permission: String): Task[Boolean] def hasAnyPermission(userId: Long, permissions: List[String]): Task[Boolean] def hasAllPermissions(userId: Long, permissions: List[String]): Task[Boolean] - Implementation: - Get user's role from AdminUserRepository - Get role's permissions - Match using wildcard logic: - "*" matches everything (Super Admin) - "blog.*" matches "blog.posts.create", "blog.categories.edit", etc. - Exact match: "blog.posts.create" matches only "blog.posts.create" - Wildcard matching algorithm: ```scala def matchesPermission(granted: String, required: String): Boolean = if granted == "*" then true else if granted.endsWith(".*") then required.startsWith(granted.dropRight(1)) // "blog.*" -> "blog." else granted == required ``` - ./mill summercms.compile succeeds - PermissionChecker.hasPermission returns true for user with matching permission - Wildcard "*" grants all permissions - Wildcard "blog.*" grants "blog.posts.create" but not "users.list" Permission registry stores plugin permissions, permission checker validates with wildcard support, role repository provides CRUD Task 2: Create permission middleware and role management routes summercms/src/auth/middleware/PermissionMiddleware.scala summercms/src/auth/routes/RoleRoutes.scala summercms/src/api/Routes.scala 1. Create PermissionMiddleware.scala: - object PermissionMiddleware: def require(permission: String): Middleware[...] - requires exactly this permission def requireAny(permissions: String*): Middleware[...] - requires any of these def requireAll(permissions: String*): Middleware[...] - requires all of these - Implementation pattern (composing with AuthMiddleware): - Get SessionClaims from context (set by AuthMiddleware) - Call PermissionChecker.hasPermission(claims.userId, permission) - If false, return 403 Forbidden with JSON: { error: "Insufficient permissions" } - If true, continue to handler - Example usage in routes: ```scala val protectedRoute = AuthMiddleware.apply @@ PermissionMiddleware.require("blog.posts.create") @@ handler { ... } ``` 2. Create RoleRoutes.scala (protected, requires "backend.manage_roles"): - GET /admin/roles - List all roles - Return: [{ id, code, name, permissions: [...], isSystem }] - GET /admin/roles/:id - Get single role - Return: { id, code, name, permissions: [...], isSystem } - POST /admin/roles (requires "backend.manage_roles") - Body: { code, name, permissions: [...] } - Validate code is unique - Return created role - PUT /admin/roles/:id (requires "backend.manage_roles") - Body: { name, permissions: [...] } - Cannot modify code or isSystem roles - Return updated role - DELETE /admin/roles/:id (requires "backend.manage_roles") - Cannot delete system roles (is_system = true) - Return 204 No Content - GET /admin/permissions - List all registered permissions (for role editor UI) - Return: { tabs: [{ name, permissions: [{code, label}] }] } 3. Update Routes.scala to include RoleRoutes with proper middleware chain - ./mill summercms.compile succeeds - GET /admin/roles returns role list (with Super Admin having "*") - POST /admin/roles creates new role - User without permission gets 403 Forbidden - User with permission gets 200 OK PermissionMiddleware enforces route-level permissions, role CRUD endpoints available, permission listing available for UI Task 3: Add optional TOTP 2FA summercms/src/auth/TotpService.scala summercms/src/auth/routes/TotpRoutes.scala summercms/src/auth/LoginService.scala summercms/src/api/Routes.scala summercms/src/Main.scala build.mill 1. Add java-totp dependency to build.mill: - mvn"dev.samstevens.totp:totp:1.7.1" 2. Create TotpService.scala (ZIO service): - trait TotpService: def generateSecret: Task[String] def generateQrCodeDataUri(secret: String, email: String): Task[String] def verifyCode(secret: String, code: String): Task[Boolean] def generateRecoveryCodes: Task[List[String]] - Implementation using dev.samstevens.totp: - DefaultSecretGenerator for secret generation - QrData.Builder with issuer "SummerCMS Admin" - ZxingPngQrGenerator for QR code - DefaultCodeVerifier for verification - RecoveryCodeGenerator for backup codes (8 codes) - Recovery codes: - Generate 8 random codes - Hash each with SHA-256 before storing (same pattern as password reset tokens) - When verifying, hash input and compare to stored hashes - Each recovery code is single-use 3. Create TotpRoutes.scala (all require auth): - POST /admin/auth/2fa/setup - Generate new secret - Return: { secret, qrCodeDataUri } - Does NOT enable 2FA yet (user must verify first) - POST /admin/auth/2fa/verify-setup - Body: { secret, code } - Verify the code matches the secret - If valid: update user with totp_secret, totp_enabled=true, generate and store recovery codes - Return: { enabled: true, recoveryCodes: [...] } (only time codes are shown!) - POST /admin/auth/2fa/disable - Body: { code } (require current TOTP code to disable) - Clear totp_secret, set totp_enabled=false, clear recovery_codes - Return: { enabled: false } - GET /admin/auth/2fa/status - Return: { enabled: boolean } 4. Update LoginService.authenticate: - After password verification, check if user has totp_enabled=true - If yes and totpCode is None: return AuthError.TotpRequired - If yes and totpCode provided: - First try TotpService.verifyCode - If fails, try matching against recovery codes (consume if match) - If no 2FA, proceed normally 5. Update Main.scala to provide TotpService.live - ./mill summercms.compile succeeds - POST /admin/auth/2fa/setup returns secret and QR code data URI - After enabling 2FA, login requires TOTP code - Recovery code can be used once in place of TOTP - POST /admin/auth/2fa/disable removes 2FA requirement 2FA can be enabled/disabled by users, login enforces 2FA when enabled, recovery codes work as backup After all tasks complete: 1. Run ./mill summercms.compile - should succeed 2. Test role creation: POST /admin/roles with new role 3. Test permission check: Access route without permission (should get 403) 4. Test wildcard: Give role "blog.*", verify "blog.posts.create" works 5. Test 2FA setup: POST /admin/auth/2fa/setup, scan QR, verify setup 6. Test 2FA login: Login without code (should fail), login with code (should succeed) 7. Test recovery code: Use recovery code instead of TOTP (should work once) - Roles can be created, updated, deleted (except system roles) - Permission middleware returns 403 for unauthorized access - Wildcard permissions work correctly ("*" and "prefix.*") - 2FA can be optionally enabled by users - 2FA login requires valid TOTP code when enabled - Recovery codes work as 2FA backup (single-use) - Super Admin role with "*" can access everything After completion, create `.planning/phases/06-backend-authentication/06-03-SUMMARY.md`