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
This commit is contained in:
Jakub Zych
2026-02-05 14:35:41 +01:00
parent 10cdd3f638
commit 1ad1993632
4 changed files with 918 additions and 5 deletions

View File

@@ -115,12 +115,12 @@ Plans:
3. Admin can reset password via email link
4. Roles can be created with specific permissions attached
5. Controllers and views check permissions before rendering protected content
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 06-01: Backend user model and login/logout
- [ ] 06-02: Password reset flow
- [ ] 06-03: RBAC permissions system
- [ ] 06-01-PLAN.md - Admin user model, login/logout, sessions with JWT + DB hybrid
- [ ] 06-02-PLAN.md - Password reset flow with secure tokens and email
- [ ] 06-03-PLAN.md - RBAC permissions system with plugin registry and optional 2FA
### Phase 7: Admin Forms & Lists
**Goal**: Generate forms and lists from YAML definitions
@@ -211,7 +211,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 3. Component System | 0/2 | Planned | - |
| 4. Theme Engine | 0/2 | Planned | - |
| 5. CLI Scaffolding | 0/2 | Planned | - |
| 6. Backend Authentication | 0/3 | Not started | - |
| 6. Backend Authentication | 0/3 | Planned | - |
| 7. Admin Forms & Lists | 0/3 | Not started | - |
| 8. Admin Dashboard | 0/2 | Not started | - |
| 9. Content Management | 0/5 | Not started | - |

View File

@@ -0,0 +1,300 @@
---
phase: 06-backend-authentication
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- summercms/resources/db/migration/V6__backend_auth.sql
- summercms/src/auth/AdminUser.scala
- summercms/src/auth/Role.scala
- summercms/src/auth/AdminSession.scala
- summercms/src/auth/LoginAttempt.scala
- summercms/src/auth/PasswordService.scala
- summercms/src/auth/SessionService.scala
- summercms/src/auth/LoginService.scala
- summercms/src/auth/AuthError.scala
- summercms/src/auth/middleware/AuthMiddleware.scala
- summercms/src/auth/routes/LoginRoutes.scala
- summercms/src/api/Routes.scala
- build.mill
autonomous: true
must_haves:
truths:
- "Admin can log in with valid email/password and receive a session token"
- "Admin receives generic error message on invalid credentials (no hints about which field is wrong)"
- "Admin can log out and the session is revoked"
- "Progressive lockout prevents brute force (10 min after 5 failures, etc.)"
- "Sessions can be listed and revoked individually"
artifacts:
- path: "summercms/resources/db/migration/V6__backend_auth.sql"
provides: "Database schema for admin_users, roles, admin_sessions, login_attempts"
contains: "CREATE TABLE admin_users"
- path: "summercms/src/auth/PasswordService.scala"
provides: "Argon2id password hashing"
exports: ["PasswordService", "hash", "verify"]
- path: "summercms/src/auth/SessionService.scala"
provides: "JWT + DB hybrid session management"
exports: ["SessionService", "createSession", "validateToken", "revokeSession"]
- path: "summercms/src/auth/LoginService.scala"
provides: "Login logic with progressive lockout"
exports: ["LoginService", "authenticate"]
- path: "summercms/src/auth/routes/LoginRoutes.scala"
provides: "POST /admin/auth/login, POST /admin/auth/logout, GET/DELETE /admin/auth/sessions"
min_lines: 50
key_links:
- from: "LoginRoutes.scala"
to: "LoginService"
via: "ZIO service dependency"
pattern: "ZIO\\.service\\[LoginService\\]"
- from: "LoginService"
to: "PasswordService"
via: "password verification"
pattern: "passwordService\\.verify"
- from: "SessionService"
to: "admin_sessions table"
via: "Quill queries"
pattern: "querySchema\\[AdminSession\\]"
---
<objective>
Create the foundation for admin backend authentication with login/logout functionality.
Purpose: Enable admin users to authenticate securely with JWT sessions backed by database for revocation support. This is the prerequisite for all protected admin functionality.
Output:
- Database migration with admin_users, roles, admin_sessions, login_attempts tables
- PasswordService with Argon2id hashing
- SessionService with JWT + DB hybrid for revocation support
- LoginService with progressive lockout protection
- Login/logout HTTP endpoints with auth middleware
</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/STATE.md
@.planning/phases/06-backend-authentication/06-CONTEXT.md
@.planning/phases/06-backend-authentication/06-RESEARCH.md
# Existing codebase patterns
@summercms/src/repository/UserRepository.scala
@summercms/src/repository/RepositoryError.scala
@summercms/src/db/QuillContext.scala
@summercms/src/api/Routes.scala
@build.mill
</context>
<tasks>
<task type="auto">
<name>Task 1: Create database migration and domain models</name>
<files>
summercms/resources/db/migration/V6__backend_auth.sql
summercms/src/auth/AdminUser.scala
summercms/src/auth/Role.scala
summercms/src/auth/AdminSession.scala
summercms/src/auth/LoginAttempt.scala
summercms/src/auth/AuthError.scala
build.mill
</files>
<action>
1. Add new dependencies to build.mill:
- mvn"com.password4j:password4j:1.8.4" (Argon2id hashing)
- mvn"com.github.jwt-scala::jwt-zio-json:11.0.3" (JWT with ZIO JSON)
2. Create V6__backend_auth.sql migration with tables:
- roles (id, code, name, permissions TEXT[], is_system, created_at)
- admin_users (id, email, password_hash, first_name, last_name, role_id FK, totp_secret, totp_enabled, recovery_codes TEXT[], created_at, updated_at, last_login_at)
- admin_sessions (id VARCHAR(64) PK, user_id FK, created_at, expires_at, ip_address INET, user_agent, revoked, revoked_at)
- login_attempts (id, email, ip_address INET, attempted_at, successful)
- Insert default Super Admin role with permissions = '{"*"}'
- Add indexes on admin_sessions(user_id), admin_sessions(expires_at), login_attempts(email, attempted_at)
3. Create domain models in summercms/src/auth/:
- AdminUser case class mapping to admin_users table (use Instant for timestamps)
- Role case class mapping to roles table
- AdminSession case class mapping to admin_sessions table
- LoginAttempt case class mapping to login_attempts table
4. Create AuthError sealed trait ADT:
- InvalidCredentials (generic - no hints)
- AccountLocked(until: Instant)
- TotpRequired
- InvalidTotp
- SessionExpired
- SessionRevoked
- InvalidToken(reason: String)
Follow existing patterns from RepositoryError.scala and model/User.scala.
</action>
<verify>
- ./mill summercms.compile succeeds
- Migration file exists at summercms/resources/db/migration/V6__backend_auth.sql
- All case classes compile with proper Instant types
</verify>
<done>
Database schema defined for auth tables, domain models created matching schema, build compiles with new dependencies
</done>
</task>
<task type="auto">
<name>Task 2: Create PasswordService, SessionService, and LoginService</name>
<files>
summercms/src/auth/PasswordService.scala
summercms/src/auth/SessionService.scala
summercms/src/auth/LoginService.scala
summercms/src/auth/repository/AdminUserRepository.scala
summercms/src/auth/repository/AdminSessionRepository.scala
summercms/src/auth/repository/LoginAttemptRepository.scala
summercms/src/config/AppConfig.scala
</files>
<action>
1. Create PasswordService (ZIO service pattern):
- trait PasswordService with hash(password: String): Task[String] and verify(password: String, hash: String): Task[Boolean]
- Implementation using Password4j with Argon2id (default settings are OWASP compliant)
- Use ZIO.attemptBlocking for Password4j calls
2. Create repository traits following existing UserRepository pattern:
- AdminUserRepository: findById, findByEmail, create, update
- AdminSessionRepository: create, findById, findByUserId, revoke, revokeAllForUser
- LoginAttemptRepository: recordAttempt, countRecentFailures(email, ip, since)
All should use RepositoryError for errors.
3. Add auth config to AppConfig.scala:
- jwtSecret: String (env: JWT_SECRET, default: generate-a-secure-secret-in-production)
- sessionDuration: Duration (default: 24 hours)
- rememberMeDuration: Duration (default: 30 days)
4. Create SessionService (ZIO service pattern):
- createSession(userId, role, rememberMe, ip, userAgent): Task[String] - returns JWT token
- validateToken(token: String): IO[AuthError, SessionClaims]
- revokeSession(sessionId: String): Task[Unit]
- listSessions(userId: Long): Task[List[AdminSession]]
- revokeAllSessions(userId: Long): Task[Unit]
- Use jwt-zio-json for JWT encoding/decoding with HS256
- Store session in DB and include sessionId in JWT claims
- On validate: decode JWT, check expiry, check DB for revocation
5. Create LoginService (ZIO service pattern):
- authenticate(email, password, totpCode: Option, ip): IO[AuthError, AdminUser]
- Progressive lockout thresholds (per RESEARCH.md):
- 5 failures: 10 min lockout
- 10 failures: 20 min lockout
- 15 failures: 1 hour lockout
- 20 failures: 24 hour lockout
- IMPORTANT: Always call passwordService.verify even for non-existent users (timing attack prevention)
- Record all login attempts (success and failure)
- Return generic InvalidCredentials error for both wrong email and wrong password
- For now, skip TOTP validation (will be added in plan 06-03)
Use ZLayer pattern like existing services. Follow _root_.config import pattern to avoid shadowing.
</action>
<verify>
- ./mill summercms.compile succeeds
- PasswordService.hash returns Argon2id hash (starts with $argon2)
- SessionService.createSession returns a JWT string
- LoginService has lockout thresholds defined
</verify>
<done>
PasswordService hashes with Argon2id, SessionService creates/validates JWT tokens with DB backing, LoginService implements progressive lockout, all repositories created
</done>
</task>
<task type="auto">
<name>Task 3: Create auth routes and middleware</name>
<files>
summercms/src/auth/middleware/AuthMiddleware.scala
summercms/src/auth/routes/LoginRoutes.scala
summercms/src/api/Routes.scala
summercms/src/Main.scala
</files>
<action>
1. Create AuthMiddleware.scala:
- Extract JWT from Authorization: Bearer header OR __Host-SID cookie
- Call SessionService.validateToken
- On success, make SessionClaims available to handlers
- On failure, return 401 Unauthorized with JSON error
2. Create LoginRoutes.scala with endpoints:
- POST /admin/auth/login
- Body: { email, password, totpCode?, rememberMe? }
- On success: Return { token, user: { id, email, firstName, lastName } }
- Set __Host-SID cookie (httpOnly, secure, sameSite=Strict, path=/admin)
- On lockout: Return 429 with Retry-After header
- On invalid: Return 401 with generic "Invalid email or password"
- POST /admin/auth/logout (requires auth)
- Revoke current session
- Clear __Host-SID cookie
- Return 200 OK
- GET /admin/auth/sessions (requires auth)
- List all sessions for current user
- Return array with id, createdAt, expiresAt, ipAddress, userAgent, current: boolean
- DELETE /admin/auth/sessions/:id (requires auth)
- Revoke specific session by ID
- Only allow revoking own sessions
- Return 204 No Content
3. Update Routes.scala to compose LoginRoutes (public) and protected routes (with AuthMiddleware)
4. Update Main.scala to provide all new service layers:
- PasswordService.live
- SessionService.live (needs AdminSessionRepository, AuthConfig)
- LoginService.live (needs AdminUserRepository, LoginAttemptRepository, PasswordService)
- AdminUserRepository.live, AdminSessionRepository.live, LoginAttemptRepository.live
Use zio.json for JSON encoding/decoding. Follow existing HealthRoutes.scala pattern for route structure.
Cookie configuration (per RESEARCH.md):
- name: __Host-SID (prefix for additional security)
- httpOnly: true
- secure: true (in production, allow http in dev)
- sameSite: Strict
- path: /admin
- maxAge: 24 hours (or 30 days with rememberMe)
</action>
<verify>
- ./mill summercms.compile succeeds
- curl -X POST localhost:8080/admin/auth/login with valid credentials returns token
- curl -X POST localhost:8080/admin/auth/logout with valid token returns 200
- curl -X GET localhost:8080/admin/auth/sessions with valid token returns session list
</verify>
<done>
Login returns JWT token and sets cookie, logout revokes session, session listing works, auth middleware protects routes
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. Run ./mill summercms.compile - should succeed
2. Run migrations against database
3. Create a test admin user manually in DB (with hashed password from PasswordService)
4. Test login flow: POST /admin/auth/login with credentials
5. Test session listing: GET /admin/auth/sessions with token
6. Test logout: POST /admin/auth/logout
7. Verify token no longer works after logout
</verification>
<success_criteria>
- Admin can log in with valid email/password and receive JWT token
- Login returns generic error for invalid credentials (no hints)
- Logout revokes the session in database
- Revoked token returns 401 on subsequent requests
- Progressive lockout kicks in after 5 failed attempts
- Sessions can be listed and individually revoked
</success_criteria>
<output>
After completion, create `.planning/phases/06-backend-authentication/06-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,279 @@
---
phase: 06-backend-authentication
plan: 02
type: execute
wave: 2
depends_on: ["06-01"]
files_modified:
- summercms/resources/db/migration/V7__password_reset.sql
- summercms/src/auth/PasswordResetToken.scala
- summercms/src/auth/PasswordResetService.scala
- summercms/src/auth/repository/PasswordResetRepository.scala
- summercms/src/auth/EmailService.scala
- summercms/src/auth/routes/PasswordResetRoutes.scala
- summercms/src/api/Routes.scala
- summercms/src/config/AppConfig.scala
- build.mill
autonomous: true
must_haves:
truths:
- "User can request password reset and receives email (if account exists)"
- "Request always returns success regardless of email existence (timing attack prevention)"
- "Reset link expires after 30 minutes"
- "After successful reset, redirect to login (no auto-login)"
- "All existing sessions are terminated when password changes"
artifacts:
- path: "summercms/src/auth/PasswordResetService.scala"
provides: "Password reset token generation and validation"
exports: ["PasswordResetService", "requestReset", "validateToken", "resetPassword"]
- path: "summercms/src/auth/EmailService.scala"
provides: "Email sending capability"
exports: ["EmailService", "sendPasswordReset"]
- path: "summercms/src/auth/routes/PasswordResetRoutes.scala"
provides: "POST /admin/auth/forgot-password, POST /admin/auth/reset-password"
min_lines: 40
key_links:
- from: "PasswordResetService"
to: "EmailService"
via: "sending reset email"
pattern: "emailService\\.sendPasswordReset"
- from: "PasswordResetService.resetPassword"
to: "SessionService.revokeAllSessions"
via: "terminate sessions on password change"
pattern: "sessionService\\.revokeAllSessions"
---
<objective>
Implement password reset flow with secure token generation and email delivery.
Purpose: Allow admin users to recover account access when they forget their password, while maintaining security through time-limited tokens and session invalidation.
Output:
- Password reset token generation with SHA-256 hashing for storage
- Email sending service (wrapped JavaMail in ZIO)
- Password reset HTTP endpoints
- Automatic session termination on password change
</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
# Dependencies from 06-01
@summercms/src/auth/AdminUser.scala
@summercms/src/auth/PasswordService.scala
@summercms/src/auth/SessionService.scala
@summercms/src/auth/repository/AdminUserRepository.scala
</context>
<tasks>
<task type="auto">
<name>Task 1: Create password reset token infrastructure</name>
<files>
summercms/resources/db/migration/V7__password_reset.sql
summercms/src/auth/PasswordResetToken.scala
summercms/src/auth/repository/PasswordResetRepository.scala
summercms/src/config/AppConfig.scala
</files>
<action>
1. Create V7__password_reset.sql migration:
- CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
ip_address INET,
used BOOLEAN DEFAULT false,
used_at TIMESTAMPTZ
)
- CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id)
- CREATE INDEX idx_password_reset_tokens_expires ON password_reset_tokens(expires_at)
2. Create PasswordResetToken case class:
- id: Long
- tokenHash: String
- userId: Long
- createdAt: Instant
- expiresAt: Instant
- ipAddress: Option[String]
- used: Boolean
- usedAt: Option[Instant]
3. Create PasswordResetRepository (following existing patterns):
- create(token: PasswordResetToken): IO[RepositoryError, PasswordResetToken]
- findByTokenHash(hash: String): IO[RepositoryError, Option[PasswordResetToken]]
- markUsed(id: Long): IO[RepositoryError, Unit]
- invalidateAllForUser(userId: Long): IO[RepositoryError, Unit]
Use Quill with querySchema mapping.
4. Add email config to AppConfig.scala:
- smtp.host: String (env: SMTP_HOST, default: localhost)
- smtp.port: Int (env: SMTP_PORT, default: 587)
- smtp.username: Option[String] (env: SMTP_USERNAME)
- smtp.password: Option[String] (env: SMTP_PASSWORD)
- smtp.from: String (env: SMTP_FROM, default: noreply@summercms.local)
- smtp.startTls: Boolean (env: SMTP_STARTTLS, default: true)
- passwordReset.tokenExpiry: Duration (default: 30 minutes)
- passwordReset.baseUrl: String (env: APP_BASE_URL, default: http://localhost:8080)
</action>
<verify>
- ./mill summercms.compile succeeds
- Migration file exists at summercms/resources/db/migration/V7__password_reset.sql
- PasswordResetRepository compiles with proper Quill queries
</verify>
<done>
Database table for password reset tokens created, domain model defined, repository with CRUD operations implemented
</done>
</task>
<task type="auto">
<name>Task 2: Create EmailService and PasswordResetService</name>
<files>
summercms/src/auth/EmailService.scala
summercms/src/auth/PasswordResetService.scala
build.mill
</files>
<action>
1. Add JavaMail dependency to build.mill:
- mvn"com.sun.mail:jakarta.mail:2.0.1"
2. Create EmailService (ZIO service pattern):
- trait EmailService:
def sendPasswordReset(to: String, token: String, ipAddress: String, requestedAt: Instant): Task[Unit]
- Implementation wraps JavaMail in ZIO.attemptBlocking
- Email content (per CONTEXT.md - includes context about the request):
- Subject: "SummerCMS Password Reset Request"
- Body includes:
- Reset link: {baseUrl}/admin/auth/reset-password?token={token}
- IP address that requested reset
- Request timestamp
- "If you didn't request this, ignore this email"
- "This link expires in 30 minutes"
- Use HTML email format with simple styling
3. Create PasswordResetService (ZIO service pattern):
- requestReset(email: String, ipAddress: String): Task[Unit]
- ALWAYS return success (timing attack prevention)
- If user exists:
- Generate 256-bit random token using SecureRandom (64 chars URL-safe base64)
- Hash with SHA-256 before storing
- Invalidate any existing tokens for this user
- Store hashed token with 30-minute expiry
- Send email with plain token
- If user doesn't exist:
- Do nothing but take same amount of time (simulate DB write + email send)
- validateToken(token: String): IO[AuthError, Long]
- Hash the provided token
- Look up in database by hash
- Check expiry
- Check not already used
- Return userId if valid
- resetPassword(token: String, newPassword: String): Task[Unit]
- Validate token (get userId)
- Hash new password with PasswordService
- Update user's password_hash
- Mark token as used
- CRITICAL: Call SessionService.revokeAllSessions(userId) to terminate all sessions
- Return success
Token generation (per RESEARCH.md):
- Use java.security.SecureRandom for entropy
- 48 bytes = 64 URL-safe base64 characters
- Hash with SHA-256 before storage
</action>
<verify>
- ./mill summercms.compile succeeds
- EmailService.sendPasswordReset can be called (logs in dev mode if SMTP not configured)
- PasswordResetService.requestReset completes for both existing and non-existing emails
</verify>
<done>
EmailService sends password reset emails, PasswordResetService generates secure tokens and handles full reset flow including session termination
</done>
</task>
<task type="auto">
<name>Task 3: Create password reset routes</name>
<files>
summercms/src/auth/routes/PasswordResetRoutes.scala
summercms/src/api/Routes.scala
summercms/src/Main.scala
</files>
<action>
1. Create PasswordResetRoutes.scala:
- POST /admin/auth/forgot-password (public)
- Body: { email: String }
- Always return 200 with { success: true, message: "If an account exists, a reset email has been sent" }
- Call PasswordResetService.requestReset in background (don't block response)
- Extract IP from request for logging
- GET /admin/auth/reset-password?token=xxx (public)
- Validate token (don't consume it)
- If valid: return 200 with { valid: true }
- If invalid/expired: return 400 with { valid: false, error: "Token invalid or expired" }
- POST /admin/auth/reset-password (public)
- Body: { token: String, password: String }
- Call PasswordResetService.resetPassword
- On success: return 200 with { success: true, message: "Password has been reset. Please log in." }
- On failure: return 400 with appropriate error
- Per CONTEXT.md: NO auto-login, redirect to login page (client-side)
2. Update Routes.scala to include PasswordResetRoutes
3. Update Main.scala to provide:
- EmailService.live (needs SmtpConfig)
- PasswordResetService.live (needs PasswordResetRepository, AdminUserRepository, PasswordService, SessionService, EmailService)
- PasswordResetRepository.live (needs Quill context)
Response format: Use zio.json for all JSON encoding.
All endpoints return JSON with consistent structure.
</action>
<verify>
- ./mill summercms.compile succeeds
- curl -X POST localhost:8080/admin/auth/forgot-password -d '{"email":"test@test.com"}' returns 200
- curl -X POST localhost:8080/admin/auth/reset-password with valid token returns 200
- After password reset, old sessions are revoked (test by trying to use old token)
</verify>
<done>
Password reset flow complete: request generates email, token validation works, password update terminates all sessions
</done>
</task>
</tasks>
<verification>
After all tasks complete:
1. Run ./mill summercms.compile - should succeed
2. Run migrations against database
3. Create a test admin user with known email
4. Test forgot password: POST /admin/auth/forgot-password
5. Check that email would be sent (verify in logs or mailhog)
6. Test token validation: GET /admin/auth/reset-password?token=xxx
7. Test password reset: POST /admin/auth/reset-password
8. Verify old session token no longer works after password change
</verification>
<success_criteria>
- POST /admin/auth/forgot-password always returns 200 (no email existence leak)
- Reset email contains IP address and timestamp of request
- Reset token expires after 30 minutes
- Password reset terminates all existing sessions
- After reset, user must log in again (no auto-login)
</success_criteria>
<output>
After completion, create `.planning/phases/06-backend-authentication/06-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,334 @@
---
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>