| Metadata | Value |
|---|---|
| Status | Active |
| Version | 1.3.0 |
| Last Updated | 2026-09-10 |
| Author | Sangeetha Grantha Team |
| Document Type | Decision record |
[!NOTE] Decision record: preserve the original rationale and check its decision/supersession status. Current runtime guidance is in system architecture and Flyway migrations.
Addendum (v1.2, 2026-06-13) — Password hashing & future direction
Password hashing (TRACK-114): Local credentials are now hashed with argon2id via
password4j1.8.2 (api/.../support/PasswordHasher.kt; cost params m=19456 KiB / t=2 / p=1 / 32-byte, OWASP minimum). The former plaintexthashPassword()is removed — no password is stored in plaintext at rest. Hashes are self-describing PHC strings, so the same helper serves the TRACK-110 admin bootstrap and any future credential path.Known gap (deployment blocker):
POST /v1/auth/tokenis gated only by the sharedADMIN_TOKENand issues a JWT with caller-suppliedroles— it verifies no password and lets a token holder self-assign roles. This is the real N1 escalation risk. The system must not be deployed beyond localhost until it is closed. (Partly closed in v1.3 below — the caller-supplied-roles half is fixed; the shared token remains.)Future direction: Authentication will move to OAuth (Google/Apple) and/or OTP (mobile/email) — passwordless. Interactive login, rehash-on-login, login throttling, and removing caller-supplied roles are tracked in TRACK-119, which will extend or supersede this ADR.
Addendum (v1.3, 2026-07-18) — Authorisation is now enforced (TRACK-112, F3)
The “Role-Based Access Control” in this ADR’s title was, until now, aspirational: roles were carried in the JWT but no route ever checked them.
authenticate("admin-auth")validates only the signature, audience and presence of auserIdclaim, so any validly-signed token — including one with an emptyroleslist — reached every admin route. There was no 403 tier at all. Three changes close that:
- Roles are derived from storage.
POST /v1/auth/tokenreads the user’srole_assignmentsinstead of copying the request’sroleslist into the JWT. Therolesfield is removed fromAuthTokenRequest; becauseignoreUnknownKeysis enabled, a client still sending it is ignored rather than rejected. The self-assign escalation is closed — holding the sharedADMIN_TOKENno longer lets a caller mint arbitrary roles.- Admin routes require a role.
Route.requireRole(a route-scoped plugin on Ktor’sAuthenticationCheckedhook, inroutes/RouteHelpers.kt) gates every admin route ongrp_sangita_admin. It intentionally does nothing when there is no principal, so an anonymous caller still receives the auth plugin’s 401 rather than a misleading 403. 401 (who are you) and 403 (you may not) are now distinct.- Refresh re-reads roles.
/v1/auth/refreshno longer carries the previous token’s claim forward, so a revoked role cannot be renewed indefinitely. It sits outsiderequireRoleso a caller whose role was revoked can still reach it.Role taxonomy is unchanged.
R__seed_01_reference.sqldefines exactly one role (grp_sangita_admin), so authorisation today is a single admin tier — the viewer/curator/admin matrix this ADR describes still has nothing to bind to. Defining that taxonomy is TRACK-119 work and needs a seed migration plus a route mapping, not just new constants. The role code now lives in one place,api/.../support/Roles.kt.Remaining deployment blockers (still TRACK-119):
- The shared
ADMIN_TOKENlogin exchange still exists and still verifies no password. Replacing it needs the OAuth/OTP work; until then, treatADMIN_TOKENas a production-grade secret.- Revocation window: enforcement reads the token’s
rolesclaim, so a role revoked mid-session stays effective until the token expires (tokenTtlSeconds, 24h default) unless the client refreshes. Closing it means a per-request storage check or a shorter TTL.Operational note: users without
grp_sangita_adminnow receive 403 where they previously had full access.bootstrap-adminassigns the role and no users are seeded, so a correctly bootstrapped environment is unaffected; users created via the user-management API need an explicit assignment.Verified end to end against the dev stack (login → JWT roles from storage → admin route 200; role-less user → 403; anonymous → 401; escalation attempt ignored) and covered by five
MoneyPathApiTestA1 scenarios.
Sangita Grantha requires authentication and authorization for the admin console and API endpoints. The platform needed to choose an authentication strategy that:
The system needs to support:
Adopt JWT Authentication with Role-Based Access Control (RBAC) using capability-based permissions.
Authentication: JWT authentication using Ktor’s jwt authentication provider
userId and roles claimsTOKEN_TTL_SECONDSBootstrap Token: ADMIN_TOKEN is retained only for issuing JWTs
POST /v1/auth/token exchanges ADMIN_TOKEN + userId for JWTAuthorization: RBAC infrastructure in place (database schema ready)
roles table with capabilities JSONB columnrole_assignments table linking users to rolesCapability-Based RBAC: Fine-grained permission system
The decision to use bearer token authentication with RBAC was driven by:
Bearer Token vs JWT:
RBAC Design:
resource.action (e.g., krithis.create, composers.update)Security Plugin (modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/plugins/Security.kt):
fun Application.configureSecurity(env: ApiEnvironment) {
install(Authentication) {
bearer("admin-auth") {
authenticate { credentials ->
if (credentials.token == env.adminToken) {
UserIdPrincipal("admin")
} else {
null
}
}
}
}
}
Configuration:
ADMIN_TOKEN environment variable (default: dev-admin-token)ApiEnvironment and passed to security pluginauthenticate("admin-auth") directiveProtected Routes:
/v1/admin/* - All admin endpoints require authentication/v1/krithis/{id} (PUT/POST) - Mutations require authenticationRoles Table (database/migrations/01__baseline-schema-and-types.sql):
CREATE TABLE IF NOT EXISTS roles (
code TEXT PRIMARY KEY,
name TEXT NOT NULL,
capabilities JSONB NOT NULL DEFAULT '{}'::jsonb
);
Role Assignments Table (database/migrations/02__domain-tables.sql):
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_code TEXT NOT NULL REFERENCES roles(code) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT timezone('UTC', now()),
PRIMARY KEY (user_id, role_code)
);
Standard Roles and Capabilities (from API Coverage Plan):
| Role Code | Description | Key Capabilities |
|---|---|---|
super_admin |
Full system access | All capabilities: true |
admin |
Content management | CRUD on all content, no user management restrictions |
editor |
Content editing | Create/Update on krithis, composers, notation; no delete/publish |
reviewer |
Content review | Read all, update workflow state, no delete |
viewer |
Read-only access | Read capabilities only |
Capability Structure (JSONB):
{
"krithis": {
"create": true,
"read": true,
"update": true,
"delete": true,
"publish": true
},
"composers": {
"create": true,
"read": true,
"update": true,
"delete": false
},
"notation": {
"create": true,
"read": true,
"update": true,
"delete": false
},
"users": {
"manage": true // All authenticated users have this
}
}
User Management Policy:
/v1/admin/usersContent Management Policy:
krithis, composers, ragas, talas, temples, tags, notation, importscreate, read, update, delete, publish (resource-specific)Planned Authorization Service:
class AuthorizationService(private val dal: SangitaDal) {
suspend fun hasPermission(userId: Uuid, permission: Permission): Boolean {
val roles = dal.users.getUserRoles(userId)
return roles.any { role ->
val capabilities = role.capabilities as? JsonObject ?: return@any false
val resourceCap = capabilities[permission.resource] as? JsonObject ?: return@any false
val actionValue = resourceCap[permission.action]?.jsonPrimitive?.content
actionValue == "true" || actionValue == true.toString()
}
}
suspend fun requirePermission(userId: Uuid, permission: Permission) {
if (!hasPermission(userId, permission)) {
throw SecurityException("User does not have permission: ${permission.resource}.${permission.action}")
}
}
}
✅ Completed:
authenticate("admin-auth") directive🔄 In Progress:
/v1/admin/users)/v1/admin/roles)📋 Planned:
Mitigation:
/v1/admin/users) (planned - Phase 3)/v1/admin/roles) (planned - Phase 4)