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

335 lines
13 KiB
Markdown

---
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"
---
<objective>
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
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto">
<name>Task 1: Create permission system</name>
<files>
summercms/src/auth/Permission.scala
summercms/src/auth/PermissionRegistry.scala
summercms/src/auth/PermissionChecker.scala
summercms/src/auth/repository/RoleRepository.scala
</files>
<action>
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
```
</action>
<verify>
- ./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"
</verify>
<done>
Permission registry stores plugin permissions, permission checker validates with wildcard support, role repository provides CRUD
</done>
</task>
<task type="auto">
<name>Task 2: Create permission middleware and role management routes</name>
<files>
summercms/src/auth/middleware/PermissionMiddleware.scala
summercms/src/auth/routes/RoleRoutes.scala
summercms/src/api/Routes.scala
</files>
<action>
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
</action>
<verify>
- ./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
</verify>
<done>
PermissionMiddleware enforces route-level permissions, role CRUD endpoints available, permission listing available for UI
</done>
</task>
<task type="auto">
<name>Task 3: Add optional TOTP 2FA</name>
<files>
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
</files>
<action>
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
</action>
<verify>
- ./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
</verify>
<done>
2FA can be enabled/disabled by users, login enforces 2FA when enabled, recovery codes work as backup
</done>
</task>
</tasks>
<verification>
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)
</verification>
<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>
<output>
After completion, create `.planning/phases/06-backend-authentication/06-03-SUMMARY.md`
</output>