Skip to content

Commit

Permalink
Merge branch 'main' into epic/ATL-4095-revocation-for-jwt-creds
Browse files Browse the repository at this point in the history
Signed-off-by: shotexa <[email protected]>
  • Loading branch information
shotexa authored Jan 9, 2024
2 parents 0da9aa4 + 614e811 commit b5c45be
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 115 deletions.
142 changes: 142 additions & 0 deletions docs/decisions/20240103-use-jwt-claims-for-agent-admin-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Use JWT claims for agent admin access control

- Status: accepted
- Deciders: Pat Losoponkul, Yurii Shynbuiev, David Poltorak, Shailesh Patil
- Date: 2024-01-03
- Tags: multitenancy, authorisation, authentication

Technical Story: [Allow agent admin role to be authenticated by Keycloak JWT | https://input-output.atlassian.net/browse/ATL-6074]

## Context and Problem Statement

Administrators currently rely on a static API key configured in the agent.
Employing a static API key for administrative operations poses a security challenge for the entire system.
A standardized centralized permission management system for administrative operations should be integrated,
to ensure the solution is security-compliant, yet remains extensible and decoupled.

The existing tenant authorization model relies on UMA (user-managed access) to protect the wallet.
In addition to the wallet usage, the agent also handles wallet management,
a functionality utilized by both tenants and administrators.
While administrators don't directly use the wallet, they oversee its management.
Integrating an auth model to distinguish between admins and tenants presents a new challenge.

- Where and how to define the role of admin and tenant?
- What should be the authorization model for the admin role?
- What boundary the admin role should be scoped to?
- How to support different deployment topologies?
- How does it interact with the wallet UMA model?

## Decision Drivers

- Must not prevent us from using other IAM systems
- Must not prevent us from supporting fine-grained tenant wallet access in the future
- Should not mix admin access with tenant access
- Should be easy to setup, configure and maintain

## Considered Options

1. Use `ClientRole` for defining roles in Keycloak

In this option, the `ClientRole` is configured at the client level,
and the user is mapped to the `ClientRole` using a role mapper.
The role claim will be available in the JWT token at `resource_access.<client_id>.roles`.

2. Use `RealmRole` for defining roles in Keycloak

In this option, the `RealmRole` is configured at the realm level,
and the user is mapped to the `RealmRole` using a role mapper.
The role claim will be available in the JWT token at `realm_acces.roles`.

3. Use custom user attribute for defining roles in Keycloak

In this option, the role is defined as a user attribute.
Then the user attribute will be included in a token using a token mapper at any pre-configured path.

## Decision Outcome

Option 1: Use `ClientRole` for defining roles in keycloak.

Example JWT payload containing `ClientRole`. (Some claims are omitted for readability)

```json
{
"exp": 1704267723,
"aud": [
"prism-agent",
"account"
],
"realm_access": {
"roles": [
"default-roles-atala-demo",
"offline_access",
"uma_authorization"
]
},
"resource_access": {
"prism-agent": {
"roles": [
"agent-admin"
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
}
}
```
The claim is available at `resource_access.<client_id>.roles` by default.
The path to the claim should be configurable by the agent to avoid vendor lock
and remain agnostic to the IAM configuration.

After introducing the role claim, there will be two distinct access control concepts.

1. Wallet access scope, where the UMA resource defines specific scopes,
providing fine-grained access to wallet operations.
For instance, Alice can update a DID but not deactivate a DID on wallet#1.

2. Agent role, which manages agent-level permissions.
For example, Alice is an admin for agent #1 and can onboard new tenants,
but this authority doesn't extend to agent #2.

__Proposed agent role authorization model__

Role is a plain text that defines what level of access a user has on a system.
For the agent, it needs to support 2 roles:

1. __Admin__: `agent-admin`. Admin can never access a tenant wallet.
Agent auth layer must ignore any UMA permission to the wallet.
2. __Tenant__: `agent-tenant` or implicitly inferred if another role is not specified.
Tenant must have UMA permission defined to access the wallet.

### Positive Consequences

- Naturally align the boundary of the agent-level role per agent instance
- Ready to use abstraction, minimal configuration to use and include the claim
- Token can be reused across clients, enabling SSO use case
- Keep the wallet access and agent-level role separated

### Negative Consequences

- The `ClientRole` is not part of the standard, other IAM systems may provide different abstraction.
- In some cases, `ClientRole` can be redundant to configure. `RealmRole` may be preferred in those scenarios.

## Pros and Cons of the Options

### Option 2: Use `RealmRole` for defining roles in Keycloak

- Good, because minimal effort is required to define the role and include it in the JWT
- Bad, because roles are at the realm level, making it hard to support some topology

*Note: This option is equally applicable as Option 1, depending on the required topology.*
### Option 3: Use custom user attribute for defining roles in Keycloak

- Bad, because role abstraction is already provided by Keycloak. Engineering effort is spent to reinvent the same concept
- Bad, because it requires more effort to configure the attribute value and map it down to the token

## Links

- [Keycloak ClientRole](https://www.keycloak.org/docs/latest/server_admin/#con-client-roles_server_administration_guide)
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package io.iohk.atala.agent.server.http

import io.iohk.atala.connect.controller.ConnectionEndpoints
import sttp.apispec.openapi.*
import sttp.apispec.{SecurityScheme, Tag}
import sttp.model.headers.AuthenticationScheme

import scala.collection.immutable.ListMap

object DocModels {

private val apiKeySecuritySchema = SecurityScheme(
`type` = "apiKey",
description = Some("API Key Authentication. The header `apikey` must be set with the API key."),
name = Some("apikey"),
in = Some("header"),
scheme = None,
bearerFormat = None,
flows = None,
openIdConnectUrl = None
)

private val adminApiKeySecuritySchema = SecurityScheme(
`type` = "apiKey",
description =
Some("Admin API Key Authentication. The header `x-admin-api-key` must be set with the Admin API key."),
name = Some("x-admin-api-key"),
in = Some("header"),
scheme = None,
bearerFormat = None,
flows = None,
openIdConnectUrl = None
)

private val jwtSecurityScheme = SecurityScheme(
`type` = "http",
description =
Some("JWT Authentication. The header `Authorization` must be set with the JWT token using `Bearer` scheme"),
name = Some("Authorization"),
in = Some("header"),
scheme = Some(AuthenticationScheme.Bearer.name),
bearerFormat = None,
flows = None,
openIdConnectUrl = None
)

val customiseDocsModel: OpenAPI => OpenAPI = { oapi =>
oapi
.info(
Info(
title = "Open Enterprise Agent API Reference",
version = "1.0", // Will be replaced dynamically by 'Tapir2StaticOAS'
summary = Some("Info - Summary"),
description = Some("Info - Description"),
termsOfService = Some("Info - Terms Of Service"),
contact = Some(
Contact(
name = Some("Contact - Name"),
email = Some("Contact - Email"),
url = Some("Contact - URL"),
extensions = ListMap.empty
)
),
license = Some(
License(
name = "License - Name",
url = Some("License - URL"),
extensions = ListMap.empty
)
),
extensions = ListMap.empty
)
)
.servers(
List(
Server(url = "http://localhost:8085", description = Some("Local Prism Agent")),
Server(url = "http://localhost/prism-agent", description = Some("Local Prism Agent with APISIX proxy")),
Server(
url = "https://k8s-dev.atalaprism.io/prism-agent",
description = Some("Prism Agent on the Staging Environment")
),
)
)
.components(
oapi.components
.getOrElse(sttp.apispec.openapi.Components.Empty)
.copy(securitySchemes =
ListMap(
"apiKeyAuth" -> Right(apiKeySecuritySchema),
"adminApiKeyAuth" -> Right(adminApiKeySecuritySchema),
"jwtAuth" -> Right(jwtSecurityScheme)
)
)
)
.addSecurity(
ListMap(
"apiKeyAuth" -> Vector.empty[String],
"adminApiKeyAuth" -> Vector.empty[String],
"jwtAuth" -> Vector.empty[String]
)
)
.tags(
List(
Tag(
ConnectionEndpoints.TAG,
Some(
s"""
|The '${ConnectionEndpoints.TAG}' endpoints facilitate the initiation of connection flows between the current agent and peer agents, regardless of whether they reside in cloud or edge environments.
|<br>
|This implementation adheres to the DIDComm Messaging v2.0 - [Out of Band Messages](https://identity.foundation/didcomm-messaging/spec/v2.0/#out-of-band-messages) specification [section 9.5.4](https://identity.foundation/didcomm-messaging/spec/v2.0/#invitation) - to generate invitations.
|The <b>from</b> field of the out-of-band invitation message contains a freshly generated Peer DID that complies with the [did:peer:2](https://identity.foundation/peer-did-method-spec/#generating-a-didpeer2) specification.
|This Peer DID includes the 'uri' location of the DIDComm messaging service, essential for the invitee's subsequent execution of the connection flow.
|<br>
|Upon accepting an invitation, the invitee sends a connection request to the inviter's DIDComm messaging service endpoint.
|The connection request's 'type' attribute must be specified as "https://atalaprism.io/mercury/connections/1.0/request".
|The inviter agent responds with a connection response message, indicated by a 'type' attribute of "https://atalaprism.io/mercury/connections/1.0/response".
|Both request and response types are proprietary to the Open Enterprise Agent ecosystem.
|""".stripMargin
)
)
)
)

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,84 +13,19 @@ import scala.collection.immutable.ListMap

object ZHttpEndpoints {

val swaggerUIOptions = SwaggerUIOptions.default
private val swaggerUIOptions = SwaggerUIOptions.default
.contextPath(List("docs", "prism-agent", "api"))

val customiseDocsModel: OpenAPI => OpenAPI = { oapi =>
oapi
.servers(
List(
Server(url = "http://localhost:8085", description = Some("Local Prism Agent")),
Server(url = "http://localhost/prism-agent", description = Some("Local Prism Agent with APISIX proxy")),
Server(
url = "https://k8s-dev.atalaprism.io/prism-agent",
description = Some("Prism Agent on the Staging Environment")
),
)
)
.components(
oapi.components
.getOrElse(sttp.apispec.openapi.Components.Empty)
.copy(securitySchemes =
ListMap(
"apiKeyAuth" -> Right(apiKeySecuritySchema),
"adminApiKeyAuth" -> Right(adminApiKeySecuritySchema),
"jwtAuth" -> Right(jwtSecurityScheme)
)
)
)
.addSecurity(
ListMap(
"apiKeyAuth" -> Vector.empty[String],
"adminApiKeyAuth" -> Vector.empty[String],
"jwtAuth" -> Vector.empty[String]
)
)

}

private val apiKeySecuritySchema = SecurityScheme(
`type` = "apiKey",
description = Some("API Key Authentication. The header `apikey` must be set with the API key."),
name = Some("apikey"),
in = Some("header"),
scheme = None,
bearerFormat = None,
flows = None,
openIdConnectUrl = None
)

private val adminApiKeySecuritySchema = SecurityScheme(
`type` = "apiKey",
description =
Some("Admin API Key Authentication. The header `x-admin-api-key` must be set with the Admin API key."),
name = Some("x-admin-api-key"),
in = Some("header"),
scheme = None,
bearerFormat = None,
flows = None,
openIdConnectUrl = None
)

private val jwtSecurityScheme = SecurityScheme(
`type` = "http",
description =
Some("JWT Authentication. The header `Authorization` must be set with the JWT token using `Bearer` scheme"),
name = Some("Authorization"),
in = Some("header"),
scheme = Some(AuthenticationScheme.Bearer.name),
bearerFormat = None,
flows = None,
openIdConnectUrl = None
)
private val redocUIOptions = RedocUIOptions.default
.copy(pathPrefix = List("redoc"))

def swaggerEndpoints[F[_]](apiEndpoints: List[ServerEndpoint[Any, F]]): List[ServerEndpoint[Any, F]] =
SwaggerInterpreter(swaggerUIOptions = swaggerUIOptions, customiseDocsModel = customiseDocsModel)
SwaggerInterpreter(swaggerUIOptions = swaggerUIOptions, customiseDocsModel = DocModels.customiseDocsModel)
.fromServerEndpoints[F](apiEndpoints, "Prism Agent", "1.0.0")

def redocEndpoints[F[_]](apiEndpoints: List[ServerEndpoint[Any, F]]): List[ServerEndpoint[Any, F]] =
RedocInterpreter(redocUIOptions = RedocUIOptions.default.copy(pathPrefix = List("redoc")))
.fromServerEndpoints[F](apiEndpoints, title = "Prism Agent", version = "1.0.0")
RedocInterpreter(redocUIOptions = redocUIOptions, customiseDocsModel = DocModels.customiseDocsModel)
.fromServerEndpoints[F](apiEndpoints, "Prism Agent", "1.0.0")

def withDocumentations[F[_]](apiEndpoints: List[ServerEndpoint[Any, F]]): List[ServerEndpoint[Any, F]] = {
apiEndpoints ++ swaggerEndpoints[F](apiEndpoints) ++ redocEndpoints[F](apiEndpoints)
Expand Down
Loading

0 comments on commit b5c45be

Please sign in to comment.