Command Authorization Extension
The PolicyCommandExtension automatically intercepts command execution to enforce authorization policies. This provides transparent authorization without modifying individual command implementations.
Overview
The command extension integrates with the IDK's command execution pipeline:
Command IDs
Commands are identified by hierarchical IDs following the pattern domain.resource.operation:
data class CommandId(
val domain: String, // e.g., "identity", "party", "kms"
val resource: String, // e.g., "identities", "parties", "keys"
val operation: String // e.g., "create", "read", "update", "delete"
) {
val fullId: String get() = "$domain.$resource.$operation"
}
// Examples
CommandId.parse("identity.identities.create")
// domain=identity, resource=identities, operation=create
CommandId.parse("party.contacts.delete")
// domain=party, resource=contacts, operation=delete
Pattern Matching
Include and exclude patterns use glob-style matching:
| Pattern | Matches |
|---|---|
identity.** | All identity commands |
*.*.create | All create operations |
party.contacts.* | All contact operations |
kms.keys.sign | Exact match |
val config = AuthZenConfig(
// Skip authorization for health and discovery
excludePatterns = listOf(
"health.**",
"discovery.**",
"actuator.**"
),
// Only authorize these patterns
includePatterns = listOf("**") // All commands
)
PolicyCommandExtension
The extension implements ICommandExecutionExtension to intercept commands:
class PolicyCommandExtension<Arg : Any, SuccessResult : Any, ErrorResult : IdkErrorType>(
private val policyEngineProvider: PolicyEngineProvider,
private val config: AuthZenConfig,
private val sessionContextProvider: () -> SessionContext
) : ICommandExecutionExtension<Arg, SuccessResult, ErrorResult> {
override fun beforeExecute(
service: Command<Arg, SuccessResult, ErrorResult>,
args: Arg,
plugin: ISurePlugin?
) {
// Skip if disabled
if (!config.enabled) return
// Check include/exclude patterns
if (!shouldAuthorize(service.id)) return
// Evaluate authorization
runBlockingCompat {
val result = evaluateAuthorization(service, args)
result.fold(
success = { decision ->
if (decision.isDenied) {
throw AuthorizationDeniedException(
commandId = service.id,
reason = decision.reasons.firstOrNull() ?: "Access denied"
)
}
},
failure = { error ->
handleError(service.id, error)
}
)
}
}
private suspend fun evaluateAuthorization(
command: Command<*, *, *>,
args: Any
): IdkResult<PolicyDecision, IdkError> {
val context = sessionContextProvider()
// Extract subjects from session (user, workload, roles)
val subjects = extractSubjects(context)
// Map command to action
val action = mapAction(command.id)
// Map arguments to resource
val resource = mapResource(command.id, args)
// Build request and evaluate
val request = PolicyRequest(
principal = subjects.first(),
action = action,
resource = resource,
context = PolicyContext(
tenantId = context.tenant.tenantId,
sessionId = context.sessionId,
timestamp = Clock.System.now().toEpochMilliseconds()
)
)
return policyEngineProvider.getEngine().evaluate(request)
}
}
Subject Extraction
The extension extracts subjects from JWT tokens in the session context:
class JwtSubjectExtractor(
private val roleClaimNames: List<String> = listOf("roles", "groups", "realm_access.roles")
) : AuthZenSubjectExtractor {
override suspend fun extractSubjects(
sessionContext: SessionContext
): IdkResult<List<AuthZenSubject>, IdkError> {
val subjects = mutableListOf<AuthZenSubject>()
// User subject from principal
val principal = sessionContext.principal
if (principal != null) {
subjects.add(AuthZenSubject(
type = "user",
id = principal.toString(),
properties = buildMap {
put("tenant_id", JsonPrimitive(sessionContext.tenant.tenantId))
sessionContext.accessToken?.let { token ->
token.email?.let { put("email", JsonPrimitive(it)) }
token.name?.let { put("name", JsonPrimitive(it)) }
}
}
))
}
// Workload subject from JWT issuer
sessionContext.accessToken?.issuer?.let { issuer ->
subjects.add(AuthZenSubject(
type = "workload",
id = issuer,
properties = emptyMap()
))
}
// Role subjects from JWT claims
val roles = extractRoles(sessionContext.accessToken)
roles.forEach { role ->
subjects.add(AuthZenSubject(
type = "role",
id = role,
properties = emptyMap()
))
}
return Ok(subjects)
}
}
Action Mapping
Commands are mapped to policy actions with operation categories:
class DefaultActionMapper : AuthZenActionMapper {
override fun mapAction(commandId: String): AuthZenAction {
val parsed = CommandId.parse(commandId)
?: return AuthZenAction(name = commandId)
return AuthZenAction(
name = parsed.fullId,
properties = buildMap {
put("domain", JsonPrimitive(parsed.domain))
put("resource", JsonPrimitive(parsed.resource))
put("operation", JsonPrimitive(parsed.operation))
put("operation_category", JsonPrimitive(
getOperationCategory(parsed.operation)
))
}
)
}
private fun getOperationCategory(operation: String): String {
return when (operation) {
"get", "list", "find", "search", "exists" -> "read"
"create", "add", "register" -> "create"
"update", "patch", "modify" -> "update"
"delete", "remove", "unregister" -> "delete"
"enable", "disable", "configure" -> "admin"
"execute", "run", "invoke" -> "execute"
"sign", "verify", "encrypt", "decrypt" -> "crypto"
else -> "other"
}
}
}
Resource Mapping
Custom mappers extract resource information from command arguments:
interface AuthZenResourceMapper<Arg> {
fun mapResource(commandId: String, args: Arg): AuthZenResource?
fun supports(commandId: String): Boolean = true
}
// Example: Identity resource mapper
class IdentityResourceMapper : AuthZenResourceMapper<IdentityArgs> {
override fun mapResource(commandId: String, args: IdentityArgs): AuthZenResource {
return AuthZenResource(
type = "Identity",
id = args.identityId,
properties = buildMap {
args.partyId?.let { put("party_id", JsonPrimitive(it)) }
args.tenantId?.let { put("tenant_id", JsonPrimitive(it)) }
}
)
}
override fun supports(commandId: String): Boolean {
return commandId.startsWith("identity.identities.")
}
}
// Register mappers
val registry = DefaultResourceMapperRegistry().apply {
register(IdentityArgs::class, IdentityResourceMapper())
register(PartyArgs::class, PartyResourceMapper())
}
Error Handling
Authorization failures throw specific exceptions:
class AuthorizationDeniedException(
val commandId: String,
val reason: String
) : RuntimeException("Authorization denied for $commandId: $reason")
class AuthorizationException(
val commandId: String,
val reason: String
) : RuntimeException("Authorization error for $commandId: $reason")
Handle in your service layer:
try {
val result = command.execute(args, context)
// Success
} catch (e: AuthorizationDeniedException) {
// User not authorized - return 403
throw ForbiddenException(e.reason)
} catch (e: AuthorizationException) {
// Authorization system error - use fallback or fail
when (config.fallbackPolicy) {
FallbackPolicy.ALLOW -> {
// Proceed anyway (development only)
command.execute(args, context)
}
FallbackPolicy.DENY -> throw ForbiddenException("Authorization unavailable")
FallbackPolicy.FAIL -> throw e
}
}
Configuration
Per-Command Exclusions
val config = AuthZenConfig(
enabled = true,
excludePatterns = listOf(
// System endpoints
"health.**",
"actuator.**",
"discovery.**",
// Public endpoints
"public.**",
// Skip specific commands
"identity.tokens.refresh",
"oauth2.*.authorize"
),
includePatterns = listOf("**")
)
Domain-Specific Authorization
val config = AuthZenConfig(
enabled = true,
excludePatterns = listOf("health.**"),
// Only authorize specific domains
includePatterns = listOf(
"party.**", // All party operations
"identity.**", // All identity operations
"kms.keys.*" // Key operations only
)
)
Spring Boot Integration
The command extension is automatically configured with Spring Boot auto-configuration:
sphereon:
authzen:
enabled: true
pdp:
type: cedarling
base-url: http://cedarling:5000
exclude-patterns:
- "health.**"
- "actuator.**"
include-patterns:
- "**"
The extension is registered as a Spring bean and injected into the command execution pipeline.
Testing
Unit Testing
@Test
fun `should deny unauthorized access`() {
// Setup mock policy engine
val mockEngine = mockk<PolicyEngine>()
coEvery { mockEngine.evaluate(any()) } returns Ok(
PolicyDecision(decision = Decision.DENY, reasons = listOf("Not authorized"))
)
val extension = PolicyCommandExtension(
policyEngineProvider = { mockEngine },
config = AuthZenConfig(enabled = true),
sessionContextProvider = { testSessionContext }
)
// Should throw
assertThrows<AuthorizationDeniedException> {
extension.beforeExecute(testCommand, testArgs, null)
}
}
@Test
fun `should skip excluded commands`() {
val config = AuthZenConfig(
enabled = true,
excludePatterns = listOf("health.**")
)
val extension = PolicyCommandExtension(
policyEngineProvider = { mockEngine },
config = config,
sessionContextProvider = { testSessionContext }
)
// Health command should not evaluate policy
extension.beforeExecute(healthCheckCommand, args, null)
coVerify(exactly = 0) { mockEngine.evaluate(any()) }
}
Integration Testing
@SpringBootTest
class AuthorizationIntegrationTest {
@Autowired
lateinit var partyService: PartyService
@Test
fun `admin can create party`() {
// Login as admin
withAdminContext {
val party = partyService.createParty(input)
assertNotNull(party)
}
}
@Test
fun `regular user cannot delete party`() {
withUserContext {
assertThrows<AuthorizationDeniedException> {
partyService.deleteParty(partyId)
}
}
}
}