Sangeetha-Grantha

Metadata Value
Status Active
Version 1.3.0
Last Updated 2026-09-10
Author Sangeetha Grantha Team
Document Type Decision record

ADR-004: Authentication Strategy - JWT with Role-Based Access Control


[!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 password4j 1.8.2 (api/.../support/PasswordHasher.kt; cost params m=19456 KiB / t=2 / p=1 / 32-byte, OWASP minimum). The former plaintext hashPassword() 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/token is gated only by the shared ADMIN_TOKEN and issues a JWT with caller-supplied roles — 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 a userId claim, so any validly-signed token — including one with an empty roles list — reached every admin route. There was no 403 tier at all. Three changes close that:

  1. Roles are derived from storage. POST /v1/auth/token reads the user’s role_assignments instead of copying the request’s roles list into the JWT. The roles field is removed from AuthTokenRequest; because ignoreUnknownKeys is enabled, a client still sending it is ignored rather than rejected. The self-assign escalation is closed — holding the shared ADMIN_TOKEN no longer lets a caller mint arbitrary roles.
  2. Admin routes require a role. Route.requireRole (a route-scoped plugin on Ktor’s AuthenticationChecked hook, in routes/RouteHelpers.kt) gates every admin route on grp_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.
  3. Refresh re-reads roles. /v1/auth/refresh no longer carries the previous token’s claim forward, so a revoked role cannot be renewed indefinitely. It sits outside requireRole so a caller whose role was revoked can still reach it.

Role taxonomy is unchanged. R__seed_01_reference.sql defines 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):

Operational note: users without grp_sangita_admin now receive 403 where they previously had full access. bootstrap-admin assigns 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 MoneyPathApiTest A1 scenarios.

Context

Sangita Grantha requires authentication and authorization for the admin console and API endpoints. The platform needed to choose an authentication strategy that:

  1. Secures admin endpoints (content management, imports, user management)
  2. Supports role-based access control (RBAC) for fine-grained permissions
  3. Integrates with Ktor server framework
  4. Scales to multiple users with different roles
  5. Supports future expansion (JWT tokens, OAuth, SSO)

The system needs to support:

Decision

Adopt JWT Authentication with Role-Based Access Control (RBAC) using capability-based permissions.

Current Implementation (v1.1)

Authentication: JWT authentication using Ktor’s jwt authentication provider

Bootstrap Token: ADMIN_TOKEN is retained only for issuing JWTs

Authorization: RBAC infrastructure in place (database schema ready)

Planned Enhancement (v2.0)

Capability-Based RBAC: Fine-grained permission system

Rationale

The decision to use bearer token authentication with RBAC was driven by:

  1. Simplicity: Bearer tokens provide simple, stateless authentication (suitable for development and small teams)
  2. Ktor Integration: Ktor’s bearer authentication provider is built-in and well-documented
  3. RBAC Foundation: Database schema (roles, role_assignments) provides foundation for fine-grained permissions
  4. Flexibility: Simple tokens can migrate to JWT without breaking API changes
  5. Security: Token-based auth separates authentication from authorization
  6. Scalability: RBAC design supports multiple users with different permission levels

Bearer Token vs JWT:

RBAC Design:

Implementation Details

Current Authentication Implementation

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:

Protected Routes:

RBAC Database Schema

Roles 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)
);

Planned RBAC Implementation

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:

Content Management Policy:

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}")
        }
    }
}

Current Implementation Status

Completed:

🔄 In Progress:

📋 Planned:

Consequences

Positive

Negative

Mitigation:

Neutral

Follow-up

References


Section index · Documentation home · Feature status