Files
Jakub Zych 1ad1993632 docs(06): create phase plan
Phase 06: Backend Authentication
- 3 plan(s) in 2 wave(s)
- Wave 1: 06-01 (login/logout foundation)
- Wave 2: 06-02 (password reset), 06-03 (RBAC + 2FA) - parallel
- Ready for execution
2026-02-05 14:35:41 +01:00

13 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
06-backend-authentication 03 execute 2
06-01
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
true
truths artifacts key_links
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
path provides exports
summercms/src/auth/PermissionRegistry.scala Plugin permission registration
PermissionRegistry
register
getAllPermissions
path provides exports
summercms/src/auth/PermissionChecker.scala Permission checking with wildcard support
PermissionChecker
hasPermission
hasAnyPermission
path provides exports
summercms/src/auth/middleware/PermissionMiddleware.scala Route-level permission enforcement
PermissionMiddleware
require
path provides exports
summercms/src/auth/TotpService.scala TOTP 2FA setup and verification
TotpService
generateSecret
verifyCode
from to via pattern
PermissionMiddleware PermissionChecker permission validation permissionChecker.hasPermission
from to via pattern
PermissionChecker RoleRepository role lookup roleRepository.findById
from to via pattern
LoginService TotpService 2FA verification 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

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>

@.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)

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/06-backend-authentication/06-03-SUMMARY.md`