| Metadata | Value |
|---|---|
| Status | Active |
| Version | 1.1.0 |
| Last Updated | 2026-09-10 |
| Author | Sangeetha Grantha Team |
| Document Type | Evidence record |
[!NOTE] Historical evidence: results, counts, commands, and observations below belong to the original work described here. The editorial update date is not a new test or corpus verification. For present behavior, use current quality checks.
Date: 2026-01-27
Status: Implementation Plan
Priority: High - Blocking Critical Functionality
Based on comprehensive analysis of two API coverage reports (API_Coverage_Report.md and frontend-backend-api-coverage-report.md), this document provides a detailed implementation plan to address:
/v1/admin/ prefix for consistencyImpact: Users cannot create, update, or manage lyric variant sections, blocking core Content Management workflow. Path inconsistencies create confusion and maintenance challenges. RBAC gaps limit fine-grained access control.
The frontend expects the following endpoints that do not exist in the backend:
| Endpoint | Method | Frontend Path | Expected Backend Path | Status |
|---|---|---|---|---|
| Create Lyric Variant | POST | /admin/krithis/{id}/variants |
/v1/admin/krithis/{id}/variants |
❌ Missing |
| Update Lyric Variant | PUT | /admin/variants/{id} |
/v1/admin/variants/{id} |
❌ Missing |
| Save Variant Sections | POST | /admin/variants/{id}/sections |
/v1/admin/variants/{id}/sections |
❌ Missing |
Current State:
GET /v1/admin/krithis/{id}/variants exists (read-only)krithi_lyric_variants, krithi_lyric_sectionsgetLyricVariants() methodKrithiServiceAdminKrithiRoutesThe notation variant implementation (AdminNotationRoutes.kt, KrithiNotationService.kt) provides an excellent reference pattern:
// Pattern: POST /v1/krithis/{id}/notation/variants
post(“/krithis/{id}/notation/variants”) {
val id = parseUuidParam(call.parameters[“id”], “krithiId”)
val request = call.receive
// Pattern: PUT /v1/notation/variants/{variantId}
put("/notation/variants/{variantId}") {
val variantId = parseUuidParam(call.parameters["variantId"], "variantId")
val request = call.receive<NotationVariantUpdateRequest>()
val updated = notationService.updateVariant(variantId, request)
call.respond(updated)
}
Key Observations:
/v1/ (not /v1/admin/) but require admin auth/v1/notation/variants/{id})/v1/krithis/{id}/notation/variants)From KrithiEditor.tsx analysis:
**Create Variant:**
{
language: LanguageCodeDto,
script: ScriptCodeDto,
transliterationScheme: string | null,
sampradayaId: string | null,
isPrimary: boolean
}
Update Variant: (same structure as create)
**Save Variant Sections:**
{
sections: Array<{
sectionId: string,
text: string
}>
}
/v1/admin/Policy: All authenticated routes that require specific roles for CRUD operations MUST use the /v1/admin/ prefix. This provides:
/v1/, Should be /v1/admin/)| Operation | Current Backend Path | Target Backend Path | Frontend Path |
|---|---|---|---|
| Create Notation | /v1/krithis/{id}/notation/variants |
/v1/admin/krithis/{id}/notation/variants |
/admin/krithis/{id}/notation/variants |
| Update Notation | /v1/notation/variants/{variantId} |
/v1/admin/notation/variants/{variantId} |
/admin/notation/variants/{variantId} |
| Delete Notation | /v1/notation/variants/{variantId} |
/v1/admin/notation/variants/{variantId} |
/admin/notation/variants/{variantId} |
| Create Row | /v1/notation/variants/{variantId}/rows |
/v1/admin/notation/variants/{variantId}/rows |
/admin/notation/variants/{variantId}/rows |
| Update Row | /v1/notation/rows/{rowId} |
/v1/admin/notation/rows/{rowId} |
/admin/notation/rows/{rowId} |
| Delete Row | /v1/notation/rows/{rowId} |
/v1/admin/notation/rows/{rowId} |
/admin/notation/rows/{rowId} |
/v1/, Should be /v1/admin/)| Operation | Current Backend Path | Target Backend Path |
|---|---|---|
| Create Krithi | /v1/krithis |
/v1/admin/krithis |
| Update Krithi | /v1/krithis/{id} |
/v1/admin/krithis/{id} |
/v1/imports/, Should be /v1/admin/imports/)| Operation | Current Backend Path | Target Backend Path |
|---|---|---|
| Submit Imports | /v1/imports/krithis |
/v1/admin/imports/krithis |
| Review Import | /v1/imports/{id}/review |
/v1/admin/imports/{id}/review |
Note: List imports endpoint already uses /v1/admin/imports, so only the mutation routes need updating.
The following routes already use /v1/admin/ prefix correctly:
/v1/admin/composers (CRUD)/v1/admin/ragas (CRUD)/v1/admin/talas (CRUD)/v1/admin/temples (CRUD)/v1/admin/tags (CRUD)/v1/admin/krithis/{id}/sections (GET/POST)/v1/admin/krithis/{id}/variants (GET)/v1/admin/krithis/{id}/tags (GET)/v1/admin/krithis/{id}/transliterate (POST)/v1/admin/krithis/{id}/validate (POST)/v1/admin/dashboard/stats (GET)/v1/admin/imports (GET)/v1/admin/imports/scrape (POST)File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/models/KrithiRequests.kt
Add the following request models (append to existing file):
@Serializable data class LyricVariantCreateRequest( val language: LanguageCodeDto, val script: ScriptCodeDto, val transliterationScheme: String? = null, val sampradayaId: String? = null, val variantLabel: String? = null, val sourceReference: String? = null, val lyrics: String = “”, // Full lyrics text (can be empty initially) val isPrimary: Boolean = false, )
@Serializable data class LyricVariantUpdateRequest( val language: LanguageCodeDto? = null, val script: ScriptCodeDto? = null, val transliterationScheme: String? = null, val sampradayaId: String? = null, val variantLabel: String? = null, val sourceReference: String? = null, val lyrics: String? = null, val isPrimary: Boolean? = null, )
@Serializable data class LyricVariantSectionRequest( val sectionId: String, val text: String, )
@Serializable
data class SaveLyricVariantSectionsRequest(
val sections: List<LyricVariantSectionRequest>
)
Dependencies:
LanguageCodeDto (from shared/domain)ScriptCodeDto (from shared/domain)kotlinx.serialization.SerializableFile: modules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/KrithiRepository.kt
Add the following methods:
suspend fun createLyricVariant( krithiId: Uuid, language: LanguageCode, script: ScriptCode, transliterationScheme: String? = null, sampradayaId: UUID? = null, variantLabel: String? = null, sourceReference: String? = null, lyrics: String, isPrimary: Boolean = false, createdByUserId: UUID? = null, updatedByUserId: UUID? = null ): KrithiLyricVariantDto = DatabaseFactory.dbQuery { val now = OffsetDateTime.now(ZoneOffset.UTC) val variantId = UUID.randomUUID()
KrithiLyricVariantsTable.insert {
it[id] = variantId
it[KrithiLyricVariantsTable.krithiId] = krithiId.toJavaUuid()
it[KrithiLyricVariantsTable.language] = language
it[KrithiLyricVariantsTable.script] = script
it[KrithiLyricVariantsTable.transliterationScheme] = transliterationScheme
it[KrithiLyricVariantsTable.sampradayaId] = sampradayaId
it[KrithiLyricVariantsTable.variantLabel] = variantLabel
it[KrithiLyricVariantsTable.sourceReference] = sourceReference
it[KrithiLyricVariantsTable.lyrics] = lyrics
it[KrithiLyricVariantsTable.isPrimary] = isPrimary
it[KrithiLyricVariantsTable.createdByUserId] = createdByUserId
it[KrithiLyricVariantsTable.updatedByUserId] = updatedByUserId
it[KrithiLyricVariantsTable.createdAt] = now
it[KrithiLyricVariantsTable.updatedAt] = now
}
KrithiLyricVariantsTable
.selectAll()
.where { KrithiLyricVariantsTable.id eq variantId }
.map { it.toKrithiLyricVariantDto() }
.single() }
suspend fun updateLyricVariant( variantId: Uuid, language: LanguageCode? = null, script: ScriptCode? = null, transliterationScheme: String? = null, sampradayaId: UUID? = null, variantLabel: String? = null, sourceReference: String? = null, lyrics: String? = null, isPrimary: Boolean? = null, updatedByUserId: UUID? = null ): KrithiLyricVariantDto? = DatabaseFactory.dbQuery { val now = OffsetDateTime.now(ZoneOffset.UTC) val updated = KrithiLyricVariantsTable.update({ KrithiLyricVariantsTable.id eq variantId.toJavaUuid() }) { language?.let { value -> it[KrithiLyricVariantsTable.language] = value } script?.let { value -> it[KrithiLyricVariantsTable.script] = value } transliterationScheme?.let { value -> it[KrithiLyricVariantsTable.transliterationScheme] = value } sampradayaId?.let { value -> it[KrithiLyricVariantsTable.sampradayaId] = value } variantLabel?.let { value -> it[KrithiLyricVariantsTable.variantLabel] = value } sourceReference?.let { value -> it[KrithiLyricVariantsTable.sourceReference] = value } lyrics?.let { value -> it[KrithiLyricVariantsTable.lyrics] = value } isPrimary?.let { value -> it[KrithiLyricVariantsTable.isPrimary] = value } updatedByUserId?.let { value -> it[KrithiLyricVariantsTable.updatedByUserId] = value } it[KrithiLyricVariantsTable.updatedAt] = now }
if (updated == 0) {
return@dbQuery null
}
KrithiLyricVariantsTable
.selectAll()
.where { KrithiLyricVariantsTable.id eq variantId.toJavaUuid() }
.map { it.toKrithiLyricVariantDto() }
.singleOrNull() }
suspend fun saveLyricVariantSections( variantId: Uuid, sections: List<Pair<UUID, String» // (sectionId, text) ) = DatabaseFactory.dbQuery { val now = OffsetDateTime.now(ZoneOffset.UTC) val javaVariantId = variantId.toJavaUuid()
// Delete existing sections for this variant
KrithiLyricSectionsTable.deleteWhere {
KrithiLyricSectionsTable.lyricVariantId eq javaVariantId
}
// Insert new sections
if (sections.isNotEmpty()) {
KrithiLyricSectionsTable.batchInsert(sections) { (sectionId, text) ->
this[KrithiLyricSectionsTable.id] = UUID.randomUUID()
this[KrithiLyricSectionsTable.lyricVariantId] = javaVariantId
this[KrithiLyricSectionsTable.sectionId] = sectionId
this[KrithiLyricSectionsTable.text] = text
this[KrithiLyricSectionsTable.normalizedText] = null // Can be computed later if needed
this[KrithiLyricSectionsTable.createdAt] = now
this[KrithiLyricSectionsTable.updatedAt] = now
}
}
}
Reference Implementation: See KrithiRepository.saveSections() for similar pattern.
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/KrithiService.kt
Add the following methods:
suspend fun createLyricVariant( krithiId: Uuid, request: com.sangita.grantha.backend.api.models.LyricVariantCreateRequest ): KrithiLyricVariantDto { // Verify krithi exists val krithi = dal.krithis.findById(krithiId) ?: throw NoSuchElementException(“Krithi not found”)
val sampradayaId = request.sampradayaId?.let { parseUuidOrThrow(it, "sampradayaId") }
val created = dal.krithis.createLyricVariant(
krithiId = krithiId,
language = LanguageCode.valueOf(request.language.name),
script = ScriptCode.valueOf(request.script.name),
transliterationScheme = request.transliterationScheme,
sampradayaId = sampradayaId,
variantLabel = request.variantLabel,
sourceReference = request.sourceReference,
lyrics = request.lyrics,
isPrimary = request.isPrimary,
createdByUserId = null, // TODO: Extract from auth context
updatedByUserId = null
)
dal.auditLogs.append(
action = "CREATE_LYRIC_VARIANT",
entityTable = "krithi_lyric_variants",
entityId = created.id
)
return created }
suspend fun updateLyricVariant( variantId: Uuid, request: com.sangita.grantha.backend.api.models.LyricVariantUpdateRequest ): KrithiLyricVariantDto { val sampradayaId = request.sampradayaId?.let { parseUuidOrThrow(it, “sampradayaId”) }
val updated = dal.krithis.updateLyricVariant(
variantId = variantId,
language = request.language?.let { LanguageCode.valueOf(it.name) },
script = request.script?.let { ScriptCode.valueOf(it.name) },
transliterationScheme = request.transliterationScheme,
sampradayaId = sampradayaId,
variantLabel = request.variantLabel,
sourceReference = request.sourceReference,
lyrics = request.lyrics,
isPrimary = request.isPrimary,
updatedByUserId = null // TODO: Extract from auth context
) ?: throw NoSuchElementException("Lyric variant not found")
dal.auditLogs.append(
action = "UPDATE_LYRIC_VARIANT",
entityTable = "krithi_lyric_variants",
entityId = updated.id
)
return updated }
suspend fun saveLyricVariantSections(
variantId: Uuid,
sections: List
val sectionsData = sections.map {
parseUuidOrThrow(it.sectionId, "sectionId") to it.text
}
dal.krithis.saveLyricVariantSections(variantId, sectionsData)
dal.auditLogs.append(
action = "UPDATE_LYRIC_VARIANT_SECTIONS",
entityTable = "krithi_lyric_sections",
entityId = variantId
) }
// Helper method needed in repository
suspend fun findLyricVariantById(variantId: Uuid): KrithiLyricVariantDto? = DatabaseFactory.dbQuery {
KrithiLyricVariantsTable
.selectAll()
.where { KrithiLyricVariantsTable.id eq variantId.toJavaUuid() }
.map { it.toKrithiLyricVariantDto() }
.singleOrNull()
}
Note: Add findLyricVariantById to KrithiRepository if it doesn’t exist.
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/AdminKrithiRoutes.kt
Add routes within the existing /v1/admin/krithis route block:
// Add after the existing get(“/{id}/variants”) route (around line 83)
post(“/{id}/variants”) {
val id = parseUuidParam(call.parameters[“id”], “krithiId”)
?: return@post call.respondText(“Missing krithi ID”, status = HttpStatusCode.BadRequest)
val request = call.receive
// Add new route block for variant-level operations
route(“/v1/admin/variants”) {
put(“/{id}”) {
val id = parseUuidParam(call.parameters[“id”], “variantId”)
?: return@put call.respondText(“Missing variant ID”, status = HttpStatusCode.BadRequest)
val request = call.receive
post("/{id}/sections") {
val id = parseUuidParam(call.parameters["id"], "variantId")
?: return@post call.respondText("Missing variant ID", status = HttpStatusCode.BadRequest)
val request = call.receive<com.sangita.grantha.backend.api.models.SaveLyricVariantSectionsRequest>()
krithiService.saveLyricVariantSections(id, request.sections)
call.respond(HttpStatusCode.NoContent)
}
}
Route Structure Decision:
/v1/admin/krithis/{id}/variants (nested under krithi, matches GET pattern)/v1/admin/variants/{id} (standalone, matches notation pattern)Alternative: Could nest all under /v1/admin/krithis/{krithiId}/variants/{variantId}, but the current pattern matches notation routes.
/v1/admin/ (HIGH PRIORITY)Policy Enforcement: All authenticated routes performing CRUD operations MUST use /v1/admin/ prefix.
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/AdminNotationRoutes.kt
Change route prefix from /v1 to /v1/admin:
fun Route.adminNotationRoutes(notationService: KrithiNotationService) { route(“/v1/admin”) { // Changed from “/v1” post(“/krithis/{id}/notation/variants”) { // … existing code … }
put("/notation/variants/{variantId}") {
// ... existing code ...
}
delete("/notation/variants/{variantId}") {
// ... existing code ...
}
post("/notation/variants/{variantId}/rows") {
// ... existing code ...
}
put("/notation/rows/{rowId}") {
// ... existing code ...
}
delete("/notation/rows/{rowId}") {
// ... existing code ...
}
}
}
Impact: Backend paths become:
/v1/admin/krithis/{id}/notation/variants/v1/admin/notation/variants/{variantId}/v1/admin/notation/variants/{variantId}/rows/v1/admin/notation/rows/{rowId}File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/AdminKrithiRoutes.kt
Move POST and PUT routes from /v1/krithis to /v1/admin/krithis:
fun Route.adminKrithiRoutes( krithiService: KrithiService, transliterationService: TransliterationService ) { route(“/v1”) { // Remove post(“/krithis”) and put(“/krithis/{id}”) from here // Move them to route(“/v1/admin/krithis”) block below }
route("/v1/admin/krithis") {
post {
val request = call.receive<KrithiCreateRequest>()
val created = krithiService.createKrithi(request)
call.respond(HttpStatusCode.Created, created)
}
put("/{id}") {
val id = parseUuidParam(call.parameters["id"], "krithiId")
?: return@put call.respondText("Missing krithi ID", status = HttpStatusCode.BadRequest)
val request = call.receive<KrithiUpdateRequest>()
val updated = krithiService.updateKrithi(id, request)
call.respond(updated)
}
// ... existing routes (sections, variants, tags, etc.) ...
}
}
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/ImportRoutes.kt
Change route prefix from /v1/imports to /v1/admin/imports:
fun Route.importRoutes(
importService: ImportService,
webScrapingService: WebScrapingService
) {
route(“/v1/admin/imports”) { // Changed from “/v1/imports”
post(“/krithis”) {
val requests = call.receive<List
// ... existing routes ...
}
route("/v1/admin/imports") {
// Move review route here as well
post("/{id}/review") {
// ... existing code ...
}
}
}
Note: The GET /v1/admin/imports route already exists, so only mutation routes need updating.
File: modules/frontend/sangita-admin-web/src/api/client.ts
Verify frontend paths match the new backend paths. The frontend already uses /admin/ prefix, so minimal changes should be needed. However, ensure all paths are consistent.
Breaking Changes: These changes are breaking changes for any external API consumers. Consider versioning or migration strategy if there are external clients.
Repository Tests:
KrithiRepositoryTest.kt (create if doesn’t exist)
createLyricVariant() with valid inputsupdateLyricVariant() updates correct fieldssaveLyricVariantSections() replaces existing sectionsService Tests:
KrithiServiceTest.kt
File: modules/backend/api/src/test/kotlin/com/sangita/grantha/backend/api/integration/
Create or extend integration tests:
class LyricVariantRoutesTest {
@Test
fun POST admin krithis variants creates variant() {
// Test create endpoint
}
@Test
fun `PUT admin variants updates variant`() {
// Test update endpoint
}
@Test
fun `POST admin variants sections saves sections`() {
// Test save sections endpoint
}
}
/v1/admin/ pathsPolicy: All authenticated users have privilege to add and remove users (universal user management access).
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/UserManagementRoutes.kt (create new file)
package com.sangita.grantha.backend.api.routes
import com.sangita.grantha.backend.api.models.* import com.sangita.grantha.backend.api.services.UserManagementService import io.ktor.http.HttpStatusCode import io.ktor.server.application.call import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.Route import io.ktor.server.routing.delete import io.ktor.server.routing.get import io.ktor.server.routing.post import io.ktor.server.routing.put import io.ktor.server.routing.route
fun Route.userManagementRoutes(userService: UserManagementService) { route(“/v1/admin/users”) { get { val users = userService.listUsers() call.respond(users) }
get("/{id}") {
val id = parseUuidParam(call.parameters["id"], "userId")
?: return@get call.respondText("Missing user ID", status = HttpStatusCode.BadRequest)
val user = userService.getUser(id)
if (user == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
} else {
call.respond(user)
}
}
post {
val request = call.receive<UserCreateRequest>()
val created = userService.createUser(request)
call.respond(HttpStatusCode.Created, created)
}
put("/{id}") {
val id = parseUuidParam(call.parameters["id"], "userId")
?: return@put call.respondText("Missing user ID", status = HttpStatusCode.BadRequest)
val request = call.receive<UserUpdateRequest>()
val updated = userService.updateUser(id, request)
if (updated == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
} else {
call.respond(updated)
}
}
delete("/{id}") {
val id = parseUuidParam(call.parameters["id"], "userId")
?: return@delete call.respondText("Missing user ID", status = HttpStatusCode.BadRequest)
val deleted = userService.deleteUser(id)
if (deleted) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respondText("User not found", status = HttpStatusCode.NotFound)
}
}
// Role assignment routes
post("/{id}/roles") {
val id = parseUuidParam(call.parameters["id"], "userId")
?: return@post call.respondText("Missing user ID", status = HttpStatusCode.BadRequest)
val request = call.receive<AssignRoleRequest>()
userService.assignRole(id, request.roleCode)
call.respond(HttpStatusCode.NoContent)
}
delete("/{id}/roles/{roleCode}") {
val id = parseUuidParam(call.parameters["id"], "userId")
?: return@delete call.respondText("Missing user ID", status = HttpStatusCode.BadRequest)
val roleCode = call.parameters["roleCode"]
?: return@delete call.respondText("Missing role code", status = HttpStatusCode.BadRequest)
val removed = userService.removeRole(id, roleCode)
if (removed) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respondText("Role assignment not found", status = HttpStatusCode.NotFound)
}
}
get("/{id}/roles") {
val id = parseUuidParam(call.parameters["id"], "userId")
?: return@get call.respondText("Missing user ID", status = HttpStatusCode.BadRequest)
val roles = userService.getUserRoles(id)
call.respond(roles)
}
}
}
Request DTOs (add to modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/models/UserRequests.kt):
@Serializable
data class UserCreateRequest(
val email: String? = null,
val fullName: String,
val displayName: String? = null,
val password: String? = null, // Optional if using external auth
val isActive: Boolean = true,
val roleCodes: List
@Serializable data class UserUpdateRequest( val email: String? = null, val fullName: String? = null, val displayName: String? = null, val password: String? = null, val isActive: Boolean? = null, )
@Serializable
data class AssignRoleRequest(
val roleCode: String,
)
Note: User management routes are accessible to all authenticated users (no additional RBAC checks beyond authentication).
Policy: Implement fine-grained role-based access control for various categories of users performing CRUD operations on Sangita Grantha content.
The system already has foundational RBAC infrastructure:
roles table (code, name, capabilities JSONB)role_assignments table (user_id, role_code)Required Enhancements:
admin, editor, reviewer, viewer)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
}
}
Standard Roles:
| 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 |
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/AuthorizationService.kt (create new file)
package com.sangita.grantha.backend.api.services
import com.sangita.grantha.backend.dal.SangitaDal import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.uuid.Uuid
data class Permission( val resource: String, // e.g., “krithis”, “composers” val action: String // e.g., “create”, “read”, “update”, “delete” )
class AuthorizationService(private val dal: SangitaDal) { suspend fun hasPermission(userId: Uuid, permission: Permission): Boolean { // Get user roles val roles = dal.users.getUserRoles(userId)
// Check if any role has the required capability
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}")
}
}
}
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/middleware/AuthorizationInterceptor.kt (create new file)
Create route interceptors that check permissions before allowing access. This can be integrated into route handlers or as a Ktor feature.
Update route handlers to check permissions:
// Example in AdminKrithiRoutes.kt post(“/v1/admin/krithis”) { val userId = getCurrentUserId(call) // Extract from auth context authorizationService.requirePermission(userId, Permission(“krithis”, “create”))
val request = call.receive<KrithiCreateRequest>()
val created = krithiService.createKrithi(request)
call.respond(HttpStatusCode.Created, created)
}
Add role management routes (admin-only):
File: modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/RoleManagementRoutes.kt (create new file)
route(“/v1/admin/roles”) { get { // List all roles (admin only) }
get("/{code}") {
// Get role details
}
post {
// Create role (super_admin only)
}
put("/{code}") {
// Update role capabilities (super_admin only)
}
get("/{code}/users") {
// List users with this role
}
}
Note: User management (add/remove users) remains accessible to all authenticated users as per policy. Role assignment and role definition management require appropriate permissions.
Critical Path (Blocking Features):
/v1/admin/ (required for consistency and RBAC)Parallel Work:
/v1/admin/ paths)sampradayaId column exists in krithi_lyric_variants table
database/migrations/02__domain-tables.sqlKrithiService.createKrithi()Routing.ktLanguageCodeDto and ScriptCodeDto exist in shared/domainsampradayaId in krithi_lyric_variantsKrithiLyricSectionsTable schema matches expected structuretoKrithiLyricVariantDto() mapper exists in DALparseUuidParam() for UUID validation201 Created for POST (create)200 OK for PUT (update)204 No Content for POST (save sections)400 Bad Request for validation errors404 Not Found for missing entitiesAUDIT_LOG tableDatabaseFactory.dbQuery {}modules/backend/api/src/main/kotlin/.../models/modules/backend/dal/src/main/kotlin/.../repositories/KrithiRepository.ktmodules/backend/api/src/main/kotlin/.../services/KrithiService.ktmodules/backend/api/src/main/kotlin/.../routes/AdminKrithiRoutes.ktpage and pageSize parametersPOST /v1/admin/krithis/{id}/validate exists but is a stub
/v1/admin/ prefix consistently/v1/admin//v1/admin/krithis/v1/admin/imports/v1/admin/usersapplication_documentation/API_Coverage_Report.mdapplication_documentation/07-quality/frontend-backend-api-coverage-report.mdapplication_documentation/06-backend/mutation-handlers.md (audit logging patterns)modules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/routes/AdminNotationRoutes.ktmodules/backend/api/src/main/kotlin/com/sangita/grantha/backend/api/services/KrithiNotationService.ktmodules/backend/dal/src/main/kotlin/com/sangita/grantha/backend/dal/repositories/KrithiRepository.kt (see saveSections())database/migrations/02__domain-tables.sql (tables: krithi_lyric_variants, krithi_lyric_sections)POST /v1/admin/krithis/{id}/variants
PUT /v1/admin/variants/{id}
POST /v1/admin/variants/{id}/sections
Notation Routes:
POST /v1/krithis/{id}/notation/variants → /v1/admin/krithis/{id}/notation/variants
PUT /v1/notation/variants/{variantId} → /v1/admin/notation/variants/{variantId}
DELETE /v1/notation/variants/{variantId} → /v1/admin/notation/variants/{variantId}
POST /v1/notation/variants/{variantId}/rows → /v1/admin/notation/variants/{variantId}/rows
PUT /v1/notation/rows/{rowId} → /v1/admin/notation/rows/{rowId}
DELETE /v1/notation/rows/{rowId} → /v1/admin/notation/rows/{rowId}
Krithi Mutation Routes:
POST /v1/krithis → /v1/admin/krithis
PUT /v1/krithis/{id} → /v1/admin/krithis/{id}
Import Routes:
POST /v1/imports/krithis → /v1/admin/imports/krithis
POST /v1/imports/{id}/review → /v1/admin/imports/{id}/review
GET /v1/admin/users
GET /v1/admin/users/{id}
POST /v1/admin/users
PUT /v1/admin/users/{id}
DELETE /v1/admin/users/{id}
POST /v1/admin/users/{id}/roles
DELETE /v1/admin/users/{id}/roles/{roleCode}
GET /v1/admin/users/{id}/roles
GET /v1/admin/roles
GET /v1/admin/roles/{code}
POST /v1/admin/roles (super_admin only)
PUT /v1/admin/roles/{code} (super_admin only)
GET /v1/admin/roles/{code}/users
All authenticated/admin routes MUST follow this pattern:
/v1/{resource} (e.g., /v1/krithis/search, /v1/krithis/{id})/v1/admin/{resource} (e.g., /v1/admin/krithis, /v1/admin/composers)/v1/admin/{resource}/{id}/{subresource} (e.g., /v1/admin/krithis/{id}/variants)Exceptions:
/health, /v1/health (no auth required)/v1/krithis/search, /v1/krithis/{id} (optional auth for enhanced features)/v1/admin/users/v1/admin/users/{id}/roles/v1/admin/roles (super_admin only)krithis, composers, ragas, talas, temples, tags, notation, importscreate, read, update, delete, publish (resource-specific)| Role | User Management | Content CRUD | Publishing | Role Management |
|---|---|---|---|---|
| super_admin | ✅ | ✅ All | ✅ | ✅ |
| admin | ✅ | ✅ All | ✅ | ❌ |
| editor | ✅ | ✅ Create/Update | ❌ | ❌ |
| reviewer | ✅ | ✅ Read/Update State | ✅ | ❌ |
| viewer | ✅ | ✅ Read Only | ❌ | ❌ |
Note: All roles have user management privileges (add/remove users) as per policy.
Document Status: Ready for Implementation
Next Steps: