diff --git a/docs/decisions/20240103-use-jwt-claims-for-agent-admin-auth.md b/docs/decisions/20240103-use-jwt-claims-for-agent-admin-auth.md new file mode 100644 index 0000000000..ec8ba72e1d --- /dev/null +++ b/docs/decisions/20240103-use-jwt-claims-for-agent-admin-auth.md @@ -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..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..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) diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/DocModels.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/DocModels.scala new file mode 100644 index 0000000000..a45080eca0 --- /dev/null +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/DocModels.scala @@ -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. + |
+ |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 from 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. + |
+ |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 + ) + ) + ) + ) + + } + +} diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/ZHttpEndpoints.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/ZHttpEndpoints.scala index 0cec3ca389..721dcae0a0 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/ZHttpEndpoints.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/agent/server/http/ZHttpEndpoints.scala @@ -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) diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/model/PaginationInput.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/model/PaginationInput.scala index ed20e9b1e0..ed6d17dba4 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/model/PaginationInput.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/api/http/model/PaginationInput.scala @@ -1,17 +1,16 @@ package io.iohk.atala.api.http.model -import sttp.tapir.EndpointIO.annotations.query +import sttp.tapir.EndpointIO.annotations.{description, query} import sttp.tapir.Schema.annotations.validateEach -import sttp.tapir.Validator -import sttp.tapir.Schema - -import io.iohk.atala.api.http.Annotation +import sttp.tapir.{Schema, Validator} case class PaginationInput( @query @validateEach(Validator.positiveOrZero[Int]) + @description("The number of items to skip before returning results. Default is 0 if not specified.") offset: Option[Int] = None, @query @validateEach(Validator.positive[Int]) + @description("The maximum number of items to return. Defaults to 100 if not specified.") limit: Option[Int] = None ) { def toPagination = Pagination.apply(this) @@ -23,21 +22,6 @@ case class Pagination(offset: Int, limit: Int) { } object Pagination { - - object annotations { - object offset - extends Annotation[Int]( - description = "The number of items to skip before returning results. Default is 0 if not specified", - example = 0 - ) - - object limit - extends Annotation[Int]( - description = "The maximum number of items to return. Defaults to 100 if not specified.", - example = 100 - ) - } - def apply(in: PaginationInput): Pagination = Pagination(in.offset.getOrElse(0), in.limit.getOrElse(100)) } diff --git a/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionEndpoints.scala b/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionEndpoints.scala index 2c557c56e3..91b611f0a4 100644 --- a/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionEndpoints.scala +++ b/prism-agent/service/server/src/main/scala/io/iohk/atala/connect/controller/ConnectionEndpoints.scala @@ -21,6 +21,8 @@ import java.util.UUID object ConnectionEndpoints { + val TAG: String = "Connections Management" + private val paginationInput: EndpointInput[PaginationInput] = EndpointInput.derived[PaginationInput] val createConnection: Endpoint[ @@ -47,16 +49,19 @@ object ConnectionEndpoints { ) ) .out(jsonBody[Connection]) - .description("The created connection record.") + .description("The newly created connection record.") .errorOut(basicFailuresAndForbidden) .name("createConnection") - .summary("Creates a new connection record and returns an Out of Band invitation.") + .summary("Create a new connection invitation that can be delivered out-of-band to a peer agent.") .description(""" - |Generates a new Peer DID and creates an [Out of Band 2.0](https://identity.foundation/didcomm-messaging/spec/v2.0/#out-of-band-messages) invitation. - |It returns a new connection record in `InvitationGenerated` state. - |The request body may contain a `label` that can be used as a human readable alias for the connection, for example `{'label': "Bob"}` + |Create a new connection invitation that can be delivered out-of-band to a peer agent, regardless of whether it resides in cloud or edge environment. + |The generated invitation 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). + |The from 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. + |In the agent database, the created connection record has an initial state set to `InvitationGenerated`. + |The request body may contain a `label` that can be used as a human readable alias for the connection, for example `{'label': "Connection with Bob"}` |""".stripMargin) - .tag("Connections Management") + .tag(TAG) val getConnection : Endpoint[(ApiKeyCredentials, JwtCredentials), (RequestContext, UUID), ErrorResponse, Connection, Any] = @@ -66,15 +71,21 @@ object ConnectionEndpoints { .in(extractFromRequest[RequestContext](RequestContext.apply)) .in( "connections" / path[UUID]("connectionId").description( - "The unique identifier of the connection record." + "The `connectionId` uniquely identifying the connection flow record." ) ) - .out(jsonBody[Connection].description("The connection record.")) + .out(jsonBody[Connection].description("The specific connection flow record.")) .errorOut(basicFailureAndNotFoundAndForbidden) .name("getConnection") - .summary("Gets an existing connection record by its unique identifier.") - .description("Gets an existing connection record by its unique identifier") - .tag("Connections Management") + .summary( + "Retrieves a specific connection flow record from the agent's database based on its unique `connectionId`." + ) + .description(""" + |Retrieve a specific connection flow record from the agent's database based in its unique `connectionId`. + |The API returns a comprehensive collection of connection flow records within the system, regardless of their state. + |The returned connection item includes essential metadata such as connection ID, thread ID, state, role, participant information, and other relevant details. + |""".stripMargin) + .tag(TAG) val getConnections: Endpoint[ (ApiKeyCredentials, JwtCredentials), @@ -89,13 +100,24 @@ object ConnectionEndpoints { .in(extractFromRequest[RequestContext](RequestContext.apply)) .in("connections") .in(paginationInput) - .in(query[Option[String]]("thid").description("The thid of a DIDComm communication.")) - .out(jsonBody[ConnectionsPage].description("The list of connection records.")) + .in( + query[Option[String]]("thid").description( + "The `thid`, shared between the inviter and the invitee, that uniquely identifies a connection flow." + ) + ) + .out( + jsonBody[ConnectionsPage].description("The list of connection flow records available from the agent's database") + ) .errorOut(basicFailuresAndForbidden) .name("getConnections") - .summary("Gets the list of connection records.") - .description("Get the list of connection records paginated") - .tag("Connections Management") + .summary("Retrieves the list of connection flow records available from the agent's database.") + .description(""" + |Retrieve of a list containing connections available from the agent's database. + |The API returns a comprehensive collection of connection flow records within the system, regardless of their state. + |Each connection item includes essential metadata such as connection ID, thread ID, state, role, participant information, and other relevant details. + |Pagination support is available, allowing for efficient handling of large datasets. + |""".stripMargin) + .tag(TAG) val acceptConnectionInvitation: Endpoint[ (ApiKeyCredentials, JwtCredentials), @@ -121,15 +143,17 @@ object ConnectionEndpoints { ) ) .out(jsonBody[Connection]) - .description("The created connection record.") + .description("The newly connection record.") .errorOut(basicFailuresAndForbidden) .name("acceptConnectionInvitation") - .summary("Accepts an Out of Band invitation.") + .summary("Accept a new connection invitation received out-of-band from another peer agent.") .description(""" - |Accepts an [Out of Band 2.0](https://identity.foundation/didcomm-messaging/spec/v2.0/#out-of-band-messages) invitation, generates a new Peer DID, - |and submits a Connection Request to the inviter. - |It returns a connection object in `ConnectionRequestPending` state, until the Connection Request is eventually sent to the inviter by the prism-agent's background process. The connection object state will then automatically move to `ConnectionRequestSent`. + |Accept an new connection invitation received out-of-band from another peer agent. + |The invitation must be compliant with 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). + |A new connection record with state `ConnectionRequestPending` will be created in the agent database and later processed by a background job to send a connection request to the peer agent. + |The created record will contain a newly generated pairwise Peer DID used for that connection. + |A connection request will then be sent to the peer agent to actually establish the connection, moving the record state to `ConnectionRequestSent`, and waiting the connection response from the peer agent. |""".stripMargin) - .tag("Connections Management") + .tag(TAG) } diff --git a/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala b/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala index d57e531d7d..43911ae66d 100644 --- a/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala +++ b/prism-agent/service/server/src/test/scala/io/iohk/atala/api/util/Tapir2StaticOAS.scala @@ -1,8 +1,9 @@ package io.iohk.atala.api.util import io.iohk.atala.agent.server.AgentHttpServer +import io.iohk.atala.agent.server.http.DocModels import io.iohk.atala.castor.controller.{DIDController, DIDRegistrarController} -import io.iohk.atala.connect.controller.ConnectionController +import io.iohk.atala.connect.controller.{ConnectionController, ConnectionEndpoints} import io.iohk.atala.credentialstatus.controller.CredentialStatusController import io.iohk.atala.event.controller.EventController import io.iohk.atala.iam.authentication.DefaultAuthenticator @@ -14,6 +15,7 @@ import io.iohk.atala.pollux.credentialschema.controller.{CredentialSchemaControl import io.iohk.atala.presentproof.controller.PresentProofController import io.iohk.atala.system.controller.SystemController import org.scalatestplus.mockito.MockitoSugar.* +import sttp.apispec.Tag import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault, ZLayer} @@ -29,7 +31,8 @@ object Tapir2StaticOAS extends ZIOAppDefault { allEndpoints <- AgentHttpServer.agentRESTServiceEndpoints } yield { import sttp.apispec.openapi.circe.yaml.* - val yaml = OpenAPIDocsInterpreter().toOpenAPI(allEndpoints.map(_.endpoint), "Prism Agent", args(1)).toYaml + val model = DocModels.customiseDocsModel(OpenAPIDocsInterpreter().toOpenAPI(allEndpoints.map(_.endpoint), "", "")) + val yaml = model.info(model.info.copy(version = args(1))).toYaml val path = Path.of(args.head) Using(Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { writer => writer.write(yaml) } }