diff --git a/Makefile b/Makefile index ab85fd10..29dd8964 100644 --- a/Makefile +++ b/Makefile @@ -64,4 +64,4 @@ test: ## Execute the Golang's tests for updatecli .PHONY: docs docs: ## Generate api documentation - swag init --parseDependencyLevel 1 + swag init --generalInfo pkg/server/endpoints.go --parseDependencyLevel 1 diff --git a/README.adoc b/README.adoc index c0e96012..40522ae6 100644 --- a/README.adoc +++ b/README.adoc @@ -30,8 +30,8 @@ Deploy Udash with the following steps: 4. Run `updatecli udash login "http://localhost" --experimental` to configure Updatecli to upload reports to Udash. 5. Then you can run any updatecli command (apply/diff) to start publishing reports to Udash -The demo runs with authentication disabled. Because no OAuth flag is passed, `udash login` skips the -authorization flow and simply records the endpoint in the Updatecli configuration file. +The demo runs with authentication disabled. `udash login` notices this, skips the token prompt, +and simply records the endpoint in the Updatecli configuration file. Please be aware that the UI is designed to visualize pipelines per git repository, so without an `scmid` pipelines will be hard to discover. @@ -91,17 +91,20 @@ with `--config`. ```yaml server: auth: - # mode selects the authentication backend. - # Accepted values are "oauth", "zitadel", and "none". + # mode selects how incoming tokens are validated. + # Accepted values are "oidc", "zitadel", and "none". # Unset or "none" disables authentication entirely. - mode: "oauth" + # An unrecognised value stops the server rather than serving an open API. + mode: "oidc" # visibility controls which endpoints require a token. # "public" (the default) leaves the read endpoints open and requires # authentication for anything that writes. # "private" requires authentication everywhere. visibility: "public" - # oauth settings, used when mode is "oauth" - oauth: + # oidc settings, used when mode is "oidc". + # Tokens are verified locally against the issuer signing keys, so this mode + # only accepts JWT access tokens. + oidc: # issuer is compared to the "iss" claim of the token, verbatim. # A scheme is optional, https is assumed when it is omitted, but the # trailing slash is significant: Auth0 issues one, Zitadel and Keycloak @@ -110,13 +113,43 @@ server: # audience is a list, and every entry is accepted. audience: - "https://udash.example/api" - # zitadel settings, used when mode is "zitadel" + # zitadel settings, used when mode is "zitadel". + # Tokens are validated by introspection, which also accepts opaque ones such + # as Zitadel personal access tokens. zitadel: domain: "xxx.region.zitadel.cloud" # keyfile is the path to a service account key file keyfile: "/etc/udash/zitadel-key.json" - # role required to access the API. Empty means any authenticated user. - role: "" + # roles maps the roles carried by a token onto Udash permissions. + roles: + # claim is the token claim holding the identity provider roles. It defaults + # to Zitadel's claim in "zitadel" mode and must be set otherwise. + # Both shapes are accepted: an object keyed by role name, as Zitadel emits, + # and an array of strings, as Keycloak and Auth0 emit. + # Zitadel: "urn:zitadel:iam:org:project:roles" + # Keycloak: "realm_access.roles" + # Auth0: "https://udash/roles" + claim: "realm_access.roles" + # mapping lists, per permission, the provider roles granting it. + mapping: + admin: ["udash.admin"] + publisher: ["udash.publisher"] + viewer: ["udash.viewer"] + # default is granted to an authenticated identity matching no role at all. + # It is deliberately the least privileged one: without it, everybody who can + # sign in could publish reports and mint API tokens. + default: "viewer" + # resolver decides how the permission behind an Udash API token is resolved, + # since such a request carries no provider token to read roles from. + # "zitadel" asks Zitadel for the current grants, so revoking a role takes + # effect on tokens created before it. It requires mode "zitadel", and the + # service user behind keyfile must be allowed to read user grants. + # "snapshot" trusts the permission recorded when the token was created, and + # is the only option for other providers. Offboarding somebody then means + # deleting their tokens. + resolver: "snapshot" + # cacheTTL is how long a resolved permission is reused. + cacheTTL: "60s" database: # uri defines the postgresql URI used to connect with its database uri: "postgres://udash:password@db:5432/udash?sslmode=disable" @@ -129,13 +162,68 @@ database: Each variable below is only a fallback: it is read when the matching key is absent from the configuration file, so the file always wins. -* **UDASH_AUTH_MODE**: Authentication mode. Accepted values are ["", "none", "oauth", "zitadel"] -* **UDASH_AUTH_OAUTH_ISSUER**: Oauth issuer URL, requires `UDASH_AUTH_MODE` set to "oauth" -* **UDASH_AUTH_OAUTH_AUDIENCE**: Oauth audience, requires `UDASH_AUTH_MODE` set to "oauth" +* **UDASH_AUTH_MODE**: Authentication mode. Accepted values are ["", "none", "oidc", "zitadel"] +* **UDASH_AUTH_OIDC_ISSUER**: OIDC issuer URL, requires `UDASH_AUTH_MODE` set to "oidc" +* **UDASH_AUTH_OIDC_AUDIENCE**: OIDC audience, requires `UDASH_AUTH_MODE` set to "oidc" * **UDASH_AUTH_ZITADEL_DOMAIN**: Zitadel domain, requires `UDASH_AUTH_MODE` set to "zitadel" -* **UDASH_AUTH_ZITADEL_FILEKEY**: Path to the Zitadel service account key file, requires `UDASH_AUTH_MODE` set to "zitadel" +* **UDASH_AUTH_ZITADEL_KEYFILE**: Path to the Zitadel service account key file, requires `UDASH_AUTH_MODE` set to "zitadel" +* **UDASH_AUTH_ROLES_CLAIM**: Token claim holding the identity provider roles +* **UDASH_AUTH_ROLES_DEFAULT**: Permission granted to an identity matching no role +* **UDASH_AUTH_ROLES_RESOLVER**: How an API token's permission is resolved ["zitadel", "snapshot"] * **UDASH_DB_URI**: Define the postgresql URI +==== Permissions + +Authorization has two axes: what a *person* may do, and what a given *token* may do. + +Permissions come from the identity provider roles, mapped by `server.auth.roles.mapping`: + +[cols="1,3"] +|=== +| Permission | Grants + +| `viewer` | read pipeline reports +| `publisher` | publish pipeline reports, and create API tokens +| `admin` | everything, plus managing any identity's tokens +|=== + +Token scopes are chosen when a token is created and can never exceed what its creator is +allowed to do: `reports:read` and `reports:write`. There is deliberately no scope for +managing tokens, so a token can never mint another one. + +==== API tokens + +An access token from an identity provider always expires, while an unattended pipeline needs +a credential it can keep. Udash therefore issues its own tokens, validates them itself, and +lets them live forever unless an expiry is set. + +They are created from **Profile ▸ Tokens** in the frontend, by anybody with the `publisher` +permission, and shown exactly once — only a sha256 of the token is stored. They are prefixed +`udash_pat_` so they can be told apart from a provider token, and recognised by secret +scanners if one ever leaks. + +Point Updatecli at one with either: + +```bash +updatecli udash login --experimental https://udash.example # prompts for the token +export UPDATECLI_UDASH_ACCESS_TOKEN="udash_pat_..." # for CI +``` + +==== Non-expiring tokens without the frontend + +In `zitadel` mode, tokens are validated by introspection, which accepts opaque tokens. A +Zitadel **personal access token** on a machine user therefore works as a permanent +credential with no Udash-side setup at all: + +1. In Zitadel, create a *service user*, grant it the project role mapped to `publisher` + (`udash.publisher` by default), and create a personal access token leaving the expiration + field empty. +2. Set `UPDATECLI_UDASH_ACCESS_TOKEN` to it in CI. + +The trade-offs against an Udash API token: every request costs an introspection round-trip to +Zitadel, and minting one needs Zitadel administrator rights, so it does not scale to letting +each team issue their own. + === Udash Frontend ==== Option diff --git a/docs/docs.go b/docs/docs.go index f8806b61..6bb6c559 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -989,6 +989,224 @@ const docTemplate = `{ } } } + }, + "/api/tokens": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List the caller's API tokens. Administrators may list everybody's with all=true. The tokens themselves are never returned.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "List API tokens", + "parameters": [ + { + "type": "boolean", + "description": "list every identity's tokens, administrators only", + "name": "all", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.APIToken" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Issue a long lived token to authenticate against the Udash API. The token is returned once and cannot be recovered afterwards.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Create an API token", + "parameters": [ + { + "description": "token to create", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateTokenRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/server.CreateTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke all API tokens created by a given identity, which is what offboarding somebody needs. Administrators only.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke every token of an identity", + "parameters": [ + { + "type": "string", + "description": "identity provider subject", + "name": "subject", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/tokens/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke one of the caller's API tokens. Administrators may revoke anybody's.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke an API token", + "parameters": [ + { + "type": "string", + "description": "token id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/whoami": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return the identity, permission and token scopes behind the credential used. Updatecli calls it to validate a token at login time.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Describe the current identity", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WhoamiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } } }, "definitions": { @@ -1141,6 +1359,45 @@ const docTemplate = `{ } } }, + "model.APIToken": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + } + } + }, "model.ConfigCondition": { "type": "object", "properties": { @@ -1785,6 +2042,72 @@ const docTemplate = `{ } } }, + "server.CreateTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "description": "ExpiresAt is when the token stops working. Leave it out for a token which\nnever expires, which is what an unattended pipeline needs.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, shown back in the token list.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do. It defaults to publishing reports, and may\nnever exceed what the identity creating it is allowed to do.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "server.CreateTokenResponse": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + }, + "token": { + "description": "Token is the credential itself. It is returned here once and never again:\nonly its hash is stored.", + "type": "string" + } + } + }, "server.DefaultResponseModel": { "type": "object", "properties": { @@ -2058,6 +2381,29 @@ const docTemplate = `{ } } }, + "server.WhoamiResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "tokenName": { + "type": "string" + } + } + }, "source.Config": { "type": "object", "properties": { @@ -2307,17 +2653,25 @@ const docTemplate = `{ } } } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Either an Udash API token, created from the tokens page and prefixed with \"udash_pat_\", or an access token from the configured identity provider. Send it as \"Bearer \u003ctoken\u003e\".", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } }` // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "", + Version: "1.0", Host: "", BasePath: "", Schemes: []string{}, - Title: "", - Description: "", + Title: "Udash API", + Description: "API for managing Updatecli pipeline reports.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index d4165c76..df0666a7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,7 +1,10 @@ { "swagger": "2.0", "info": { - "contact": {} + "description": "API for managing Updatecli pipeline reports.", + "title": "Udash API", + "contact": {}, + "version": "1.0" }, "paths": { "/api/": { @@ -978,6 +981,224 @@ } } } + }, + "/api/tokens": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List the caller's API tokens. Administrators may list everybody's with all=true. The tokens themselves are never returned.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "List API tokens", + "parameters": [ + { + "type": "boolean", + "description": "list every identity's tokens, administrators only", + "name": "all", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.APIToken" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Issue a long lived token to authenticate against the Udash API. The token is returned once and cannot be recovered afterwards.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Create an API token", + "parameters": [ + { + "description": "token to create", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.CreateTokenRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/server.CreateTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke all API tokens created by a given identity, which is what offboarding somebody needs. Administrators only.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke every token of an identity", + "parameters": [ + { + "type": "string", + "description": "identity provider subject", + "name": "subject", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/tokens/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Revoke one of the caller's API tokens. Administrators may revoke anybody's.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Revoke an API token", + "parameters": [ + { + "type": "string", + "description": "token id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } + }, + "/api/whoami": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return the identity, permission and token scopes behind the credential used. Updatecli calls it to validate a token at login time.", + "produces": [ + "application/json" + ], + "tags": [ + "Tokens" + ], + "summary": "Describe the current identity", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.WhoamiResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/server.DefaultResponseModel" + } + } + } + } } }, "definitions": { @@ -1130,6 +1351,45 @@ } } }, + "model.APIToken": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + } + } + }, "model.ConfigCondition": { "type": "object", "properties": { @@ -1774,6 +2034,72 @@ } } }, + "server.CreateTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "description": "ExpiresAt is when the token stops working. Leave it out for a token which\nnever expires, which is what an unattended pipeline needs.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, shown back in the token list.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do. It defaults to publishing reports, and may\nnever exceed what the identity creating it is allowed to do.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "server.CreateTokenResponse": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt is when the token was issued.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is when the token stops working. Nil means it never expires.", + "type": "string" + }, + "id": { + "type": "string" + }, + "last_used_at": { + "description": "LastUsedAt is when the token last authenticated a request, if ever.", + "type": "string" + }, + "name": { + "description": "Name is what the token is for, chosen by whoever created it.", + "type": "string" + }, + "permission": { + "description": "Permission is what that identity could do when the token was issued.", + "type": "string" + }, + "scopes": { + "description": "Scopes is what the token may do, always a subset of what Permission allows.", + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "description": "Subject is the identity provider subject which created the token.", + "type": "string" + }, + "token": { + "description": "Token is the credential itself. It is returned here once and never again:\nonly its hash is stored.", + "type": "string" + } + } + }, "server.DefaultResponseModel": { "type": "object", "properties": { @@ -2047,6 +2373,29 @@ } } }, + "server.WhoamiResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "permission": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "tokenName": { + "type": "string" + } + } + }, "source.Config": { "type": "object", "properties": { @@ -2296,5 +2645,13 @@ } } } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Either an Udash API token, created from the tokens page and prefixed with \"udash_pat_\", or an access token from the configured identity provider. Send it as \"Bearer \u003ctoken\u003e\".", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c63fd7b0..79c62a3c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -140,6 +140,38 @@ definitions: description: UpdatedAt represents the last update date of the report. type: string type: object + model.APIToken: + properties: + created_at: + description: CreatedAt is when the token was issued. + type: string + expires_at: + description: ExpiresAt is when the token stops working. Nil means it never + expires. + type: string + id: + type: string + last_used_at: + description: LastUsedAt is when the token last authenticated a request, if + ever. + type: string + name: + description: Name is what the token is for, chosen by whoever created it. + type: string + permission: + description: Permission is what that identity could do when the token was + issued. + type: string + scopes: + description: Scopes is what the token may do, always a subset of what Permission + allows. + items: + type: string + type: array + subject: + description: Subject is the identity provider subject which created the token. + type: string + type: object model.ConfigCondition: properties: config: @@ -596,6 +628,63 @@ definitions: reportid: type: string type: object + server.CreateTokenRequest: + properties: + expires_at: + description: |- + ExpiresAt is when the token stops working. Leave it out for a token which + never expires, which is what an unattended pipeline needs. + type: string + name: + description: Name is what the token is for, shown back in the token list. + type: string + scopes: + description: |- + Scopes is what the token may do. It defaults to publishing reports, and may + never exceed what the identity creating it is allowed to do. + items: + type: string + type: array + required: + - name + type: object + server.CreateTokenResponse: + properties: + created_at: + description: CreatedAt is when the token was issued. + type: string + expires_at: + description: ExpiresAt is when the token stops working. Nil means it never + expires. + type: string + id: + type: string + last_used_at: + description: LastUsedAt is when the token last authenticated a request, if + ever. + type: string + name: + description: Name is what the token is for, chosen by whoever created it. + type: string + permission: + description: Permission is what that identity could do when the token was + issued. + type: string + scopes: + description: Scopes is what the token may do, always a subset of what Permission + allows. + items: + type: string + type: array + subject: + description: Subject is the identity provider subject which created the token. + type: string + token: + description: |- + Token is the credential itself. It is returned here once and never again: + only its hash is stored. + type: string + type: object server.DefaultResponseModel: properties: error: @@ -835,6 +924,21 @@ definitions: description: TotalCount is the total number of targets for pagination. type: integer type: object + server.WhoamiResponse: + properties: + name: + type: string + permission: + type: string + scopes: + items: + type: string + type: array + subject: + type: string + tokenName: + type: string + type: object source.Config: properties: dependsOn: @@ -1095,6 +1199,9 @@ definitions: type: object info: contact: {} + description: API for managing Updatecli pipeline reports. + title: Udash API + version: "1.0" paths: /api/: get: @@ -1744,4 +1851,154 @@ paths: summary: Search SCMs tags: - SCMs + /api/tokens: + delete: + description: Revoke all API tokens created by a given identity, which is what + offboarding somebody needs. Administrators only. + parameters: + - description: identity provider subject + in: query + name: subject + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "403": + description: Forbidden + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Revoke every token of an identity + tags: + - Tokens + get: + description: List the caller's API tokens. Administrators may list everybody's + with all=true. The tokens themselves are never returned. + parameters: + - description: list every identity's tokens, administrators only + in: query + name: all + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.APIToken' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: List API tokens + tags: + - Tokens + post: + consumes: + - application/json + description: Issue a long lived token to authenticate against the Udash API. + The token is returned once and cannot be recovered afterwards. + parameters: + - description: token to create + in: body + name: request + required: true + schema: + $ref: '#/definitions/server.CreateTokenRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/server.CreateTokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "403": + description: Forbidden + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Create an API token + tags: + - Tokens + /api/tokens/{id}: + delete: + description: Revoke one of the caller's API tokens. Administrators may revoke + anybody's. + parameters: + - description: token id + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + "404": + description: Not Found + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Revoke an API token + tags: + - Tokens + /api/whoami: + get: + description: Return the identity, permission and token scopes behind the credential + used. Updatecli calls it to validate a token at login time. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/server.WhoamiResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/server.DefaultResponseModel' + security: + - BearerAuth: [] + summary: Describe the current identity + tags: + - Tokens +securityDefinitions: + BearerAuth: + description: Either an Udash API token, created from the tokens page and prefixed + with "udash_pat_", or an access token from the configured identity provider. + Send it as "Bearer ". + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/pkg/database/apitoken.go b/pkg/database/apitoken.go new file mode 100644 index 00000000..6affd261 --- /dev/null +++ b/pkg/database/apitoken.go @@ -0,0 +1,224 @@ +package database + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/sirupsen/logrus" + "github.com/updatecli/udash/pkg/model" + + "github.com/stephenafamo/bob" + "github.com/stephenafamo/bob/dialect/psql" + "github.com/stephenafamo/bob/dialect/psql/dialect" + "github.com/stephenafamo/bob/dialect/psql/dm" + "github.com/stephenafamo/bob/dialect/psql/im" + "github.com/stephenafamo/bob/dialect/psql/sm" + "github.com/stephenafamo/bob/dialect/psql/um" +) + +// ErrAPITokenNotFound is returned when no token matches the request. +var ErrAPITokenNotFound = errors.New("api token not found") + +// apiTokenColumns is the column list every read shares, in scan order. +var apiTokenColumns = []any{ + "id", "name", "subject", "permission", "scopes", + "created_at", "last_used_at", "expires_at", +} + +// InsertAPIToken stores a new token and returns it. +// +// Only the hash is passed in: the token itself is shown once, to whoever created +// it, and is never written down. +func InsertAPIToken(ctx context.Context, name, subject, permission string, scopes []string, tokenHash []byte, expiresAt *time.Time) (*model.APIToken, error) { + query := psql.Insert( + im.Into("api_tokens", "name", "subject", "permission", "scopes", "token_hash", "expires_at"), + im.Values( + psql.Arg(name), + psql.Arg(subject), + psql.Arg(permission), + psql.Arg(scopes), + psql.Arg(tokenHash), + psql.Arg(expiresAt), + ), + im.Returning(apiTokenColumns...), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return nil, err + } + + token := model.APIToken{} + if err := scanAPIToken(DB.QueryRow(ctx, queryString, args...), &token); err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return nil, err + } + + return &token, nil +} + +// GetAPITokenByHash returns the token matching the given hash. +// +// Looking a token up by its hash is what makes the stored value useless to anybody +// who reads the database: it cannot be turned back into a usable credential. +func GetAPITokenByHash(ctx context.Context, tokenHash []byte) (*model.APIToken, error) { + query := psql.Select( + sm.Columns(apiTokenColumns...), + sm.From("api_tokens"), + sm.Where(psql.Quote("token_hash").EQ(psql.Arg(tokenHash))), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return nil, err + } + + token := model.APIToken{} + if err := scanAPIToken(DB.QueryRow(ctx, queryString, args...), &token); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAPITokenNotFound + } + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return nil, err + } + + return &token, nil +} + +// ListAPITokens returns the tokens of a subject, or every token when subject is +// empty, which only an administrator may ask for. +func ListAPITokens(ctx context.Context, subject string) ([]model.APIToken, error) { + mods := []bob.Mod[*dialect.SelectQuery]{ + sm.Columns(apiTokenColumns...), + sm.From("api_tokens"), + sm.OrderBy("created_at").Desc(), + } + if subject != "" { + mods = append(mods, sm.Where(psql.Quote("subject").EQ(psql.Arg(subject)))) + } + + query := psql.Select(mods...) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return nil, err + } + + rows, err := DB.Query(ctx, queryString, args...) + if err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return nil, err + } + defer rows.Close() + + tokens := []model.APIToken{} + for rows.Next() { + token := model.APIToken{} + if err := scanAPIToken(rows, &token); err != nil { + logrus.Errorf("parsing result: %s", err) + return nil, err + } + tokens = append(tokens, token) + } + + return tokens, rows.Err() +} + +// DeleteAPIToken removes a token. A non empty subject restricts the deletion to +// that subject's own tokens, so one identity cannot revoke another's. +func DeleteAPIToken(ctx context.Context, id uuid.UUID, subject string) error { + mods := []bob.Mod[*dialect.DeleteQuery]{ + dm.From("api_tokens"), + dm.Where(psql.Quote("id").EQ(psql.Arg(id))), + } + if subject != "" { + mods = append(mods, dm.Where(psql.Quote("subject").EQ(psql.Arg(subject)))) + } + + query := psql.Delete(mods...) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return err + } + + result, err := DB.Exec(ctx, queryString, args...) + if err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return err + } + + if result.RowsAffected() == 0 { + return ErrAPITokenNotFound + } + + return nil +} + +// DeleteAPITokensBySubject removes every token of a subject, which is what +// offboarding an identity needs. +func DeleteAPITokensBySubject(ctx context.Context, subject string) (int64, error) { + query := psql.Delete( + dm.From("api_tokens"), + dm.Where(psql.Quote("subject").EQ(psql.Arg(subject))), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + logrus.Errorf("building query failed: %s\n\t%s", queryString, err) + return 0, err + } + + result, err := DB.Exec(ctx, queryString, args...) + if err != nil { + logrus.Errorf("query failed: %q\n\t%s", queryString, err) + return 0, err + } + + return result.RowsAffected(), nil +} + +// TouchAPIToken records that a token was just used. +// +// A failure here is not worth failing the request it belongs to: the timestamp is +// there to help somebody spot an unused or a leaked token, not to authorize. +func TouchAPIToken(ctx context.Context, id uuid.UUID) error { + query := psql.Update( + um.Table("api_tokens"), + um.SetCol("last_used_at").ToArg(time.Now()), + um.Where(psql.Quote("id").EQ(psql.Arg(id))), + ) + + queryString, args, err := query.Build(ctx) + if err != nil { + return err + } + + _, err = DB.Exec(ctx, queryString, args...) + return err +} + +// scanner is what pgx rows and single rows have in common. +type scanner interface { + Scan(dest ...any) error +} + +func scanAPIToken(row scanner, token *model.APIToken) error { + return row.Scan( + &token.ID, + &token.Name, + &token.Subject, + &token.Permission, + &token.Scopes, + &token.CreatedAt, + &token.LastUsedAt, + &token.ExpiresAt, + ) +} diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index a326c3b0..03994174 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -74,7 +74,7 @@ func TestDatabase(t *testing.T) { Result: result.SUCCESS, ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", PipelineID: "venom", - }) + }, Publisher{}) require.NoError(t, err) t.Cleanup(func() { _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) @@ -171,7 +171,7 @@ func TestDatabase(t *testing.T) { for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { - id, err := InsertReport(ctx, tt.report) + id, err := InsertReport(ctx, tt.report, Publisher{}) require.NoError(t, err) t.Cleanup(func() { _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) @@ -214,7 +214,7 @@ func TestDatabase(t *testing.T) { } for range 3 { - id, err := InsertReport(ctx, report) + id, err := InsertReport(ctx, report, Publisher{}) require.NoError(t, err) t.Cleanup(func() { _, err := DB.Exec(ctx, "DELETE FROM pipelineReports WHERE id = $1", id) diff --git a/pkg/database/migration_test.go b/pkg/database/migration_test.go new file mode 100644 index 00000000..8f6602b5 --- /dev/null +++ b/pkg/database/migration_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "context" + "testing" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/stretchr/testify/require" + "github.com/updatecli/udash/test" +) + +// firstReversibleVersion is where this test starts rolling back from. +// +// Everything from here up must be reversible. Going further down does not work +// today: migration 000003 recreates its index with jsonb_path_ops while the +// column it migrates back to is json, so postgres rejects it. That predates the +// migrations this test covers and is left alone. +const firstReversibleVersion = 11 + +// TestMigrationsAreReversible walks the recent migrations down and back up. +// +// A migration which cannot be undone is only discovered when a rollback is +// needed, which is the worst moment to find out. +func TestMigrationsAreReversible(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, Connect(Options{URI: dbURL})) + + source, err := iofs.New(fs, "migrations") + require.NoError(t, err) + + m, err := migrate.NewWithSourceInstance("iofs", source, URI) + require.NoError(t, err) + + require.NoError(t, m.Up()) + require.NoError(t, m.Migrate(firstReversibleVersion)) + require.NoError(t, m.Up()) + + // The tables the latest migrations add must be back. + for _, table := range []string{"api_tokens", "pipelinereports"} { + var exists bool + require.NoError(t, DB.QueryRow(ctx, + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)", table, + ).Scan(&exists)) + require.True(t, exists, "table %q must exist after migrating back up", table) + } + + var count int + require.NoError(t, DB.QueryRow(ctx, + `SELECT count(*) FROM information_schema.columns + WHERE table_name = 'pipelinereports' + AND column_name IN ('created_by_subject', 'created_by_token_id')`, + ).Scan(&count)) + require.Equal(t, 2, count, "the attribution columns must exist after migrating back up") +} diff --git a/pkg/database/migrations/000012_create_api_tokens.down.sql b/pkg/database/migrations/000012_create_api_tokens.down.sql new file mode 100644 index 00000000..4972c423 --- /dev/null +++ b/pkg/database/migrations/000012_create_api_tokens.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_api_tokens_subject; +DROP TABLE IF EXISTS api_tokens; + +COMMIT; diff --git a/pkg/database/migrations/000012_create_api_tokens.up.sql b/pkg/database/migrations/000012_create_api_tokens.up.sql new file mode 100644 index 00000000..9610b734 --- /dev/null +++ b/pkg/database/migrations/000012_create_api_tokens.up.sql @@ -0,0 +1,32 @@ +BEGIN; + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS api_tokens( + id uuid DEFAULT uuid_generate_v4 (), + name VARCHAR NOT NULL, + -- Only the sha256 of the token is kept: the token itself is shown once, when + -- it is created, and can never be recovered from here. + token_hash BYTEA NOT NULL, + -- subject is the identity provider subject which created the token. + subject VARCHAR NOT NULL, + -- permission is what the creator could do when the token was issued. It bounds + -- the token when the current permission cannot be looked up. + permission VARCHAR NOT NULL, + scopes TEXT[] NOT NULL DEFAULT '{}', + -- These are TIMESTAMPTZ, unlike the older tables: expiry is compared against + -- the current instant, and a TIMESTAMP drops the offset on the way back out, + -- which moves a token's expiry by the server's UTC offset. + created_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + -- A NULL expiry means the token never expires, which is the point of it. + expires_at TIMESTAMPTZ, + CONSTRAINT api_tokens_pkey PRIMARY KEY (id), + CONSTRAINT api_tokens_token_hash_unique UNIQUE (token_hash) +); + +ALTER TABLE api_tokens ALTER COLUMN created_at SET DEFAULT now(); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_subject ON api_tokens (subject); + +COMMIT; diff --git a/pkg/database/migrations/000013_alter_pipelineReports_attribution.down.sql b/pkg/database/migrations/000013_alter_pipelineReports_attribution.down.sql new file mode 100644 index 00000000..69659d8a --- /dev/null +++ b/pkg/database/migrations/000013_alter_pipelineReports_attribution.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_pipelinereports_created_by_subject; +ALTER TABLE pipelineReports DROP COLUMN IF EXISTS created_by_token_id; +ALTER TABLE pipelineReports DROP COLUMN IF EXISTS created_by_subject; + +COMMIT; diff --git a/pkg/database/migrations/000013_alter_pipelineReports_attribution.up.sql b/pkg/database/migrations/000013_alter_pipelineReports_attribution.up.sql new file mode 100644 index 00000000..ca7a5495 --- /dev/null +++ b/pkg/database/migrations/000013_alter_pipelineReports_attribution.up.sql @@ -0,0 +1,11 @@ +BEGIN; + +-- Who published a report. Both are nullable: reports published before this +-- migration have no attribution, and neither do reports published against an +-- instance running without authentication. +ALTER TABLE pipelineReports ADD COLUMN IF NOT EXISTS created_by_subject VARCHAR; +ALTER TABLE pipelineReports ADD COLUMN IF NOT EXISTS created_by_token_id uuid; + +CREATE INDEX IF NOT EXISTS idx_pipelinereports_created_by_subject ON pipelineReports (created_by_subject); + +COMMIT; diff --git a/pkg/database/report.go b/pkg/database/report.go index 23364bb5..9e416dd3 100644 --- a/pkg/database/report.go +++ b/pkg/database/report.go @@ -633,7 +633,18 @@ func nextBucket(t time.Time, granularity SummaryGranularity) time.Time { } // InsertReport inserts a new report into the database. -func InsertReport(ctx context.Context, report reports.Report) (string, error) { +// Publisher identifies who published a report. +// +// Both fields are optional: an instance running without authentication has nobody +// to attribute a report to, and a report published from the browser has no token. +type Publisher struct { + // Subject is the identity provider subject which published the report. + Subject *string + // TokenID is the API token used, when one was. + TokenID *uuid.UUID +} + +func InsertReport(ctx context.Context, report reports.Report, publisher Publisher) (string, error) { var err error configTargetIDs := pgtype.Hstore{} configConditionIDs := pgtype.Hstore{} @@ -803,6 +814,8 @@ func InsertReport(ctx context.Context, report reports.Report) (string, error) { "config_condition_ids", "config_target_ids", "label_ids", + "created_by_subject", + "created_by_token_id", ), im.Values( psql.Arg(report), @@ -814,6 +827,8 @@ func InsertReport(ctx context.Context, report reports.Report) (string, error) { psql.Arg(configConditionIDs), psql.Arg(configTargetIDs), psql.Arg(labelIDs), + psql.Arg(publisher.Subject), + psql.Arg(publisher.TokenID), ), im.Returning("id"), ) diff --git a/pkg/model/apitoken.go b/pkg/model/apitoken.go new file mode 100644 index 00000000..7ec9dcb5 --- /dev/null +++ b/pkg/model/apitoken.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// APIToken is a long lived credential Udash issues and validates itself. +// +// It exists because an identity provider access token always expires, while a CI +// pipeline needs a credential it can keep for as long as it runs unattended. +type APIToken struct { + ID uuid.UUID `json:"id"` + // Name is what the token is for, chosen by whoever created it. + Name string `json:"name"` + // Subject is the identity provider subject which created the token. + Subject string `json:"subject"` + // Permission is what that identity could do when the token was issued. + Permission string `json:"permission"` + // Scopes is what the token may do, always a subset of what Permission allows. + Scopes []string `json:"scopes"` + // CreatedAt is when the token was issued. + CreatedAt time.Time `json:"created_at"` + // LastUsedAt is when the token last authenticated a request, if ever. + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + // ExpiresAt is when the token stops working. Nil means it never expires. + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} diff --git a/pkg/server/endpoints.go b/pkg/server/endpoints.go index 0a0e0755..bc0b8955 100644 --- a/pkg/server/endpoints.go +++ b/pkg/server/endpoints.go @@ -2,10 +2,8 @@ package server import ( "context" - "log/slog" + "fmt" "net/http" - "os" - "strings" _ "github.com/updatecli/udash/docs" "github.com/zitadel/zitadel-go/v3/pkg/authorization" @@ -78,12 +76,20 @@ func About(c *gin.Context) { // @title Udash API // @version 1.0 // @description API for managing Updatecli pipeline reports. -// @BasePath /api/ +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Either an Udash API token, created from the tokens page and prefixed with "udash_pat_", or an access token from the configured identity provider. Send it as "Bearer ". func (s *Server) Run() error { // Init Server Option - s.Options.Init() + if err := s.Options.Init(); err != nil { + return fmt.Errorf("invalid server options: %w", err) + } - r := newGinEngine(s.Options) + r, err := newGinEngine(s.Options) + if err != nil { + return err + } // listen and server on 0.0.0.0:8080 return r.Run() @@ -106,22 +112,7 @@ func publicReadOnly(auth gin.HandlerFunc) gin.HandlerFunc { } } -// zitadelAuthorization requires a valid token, and the configured role when there is one. -// -// An empty role must not be passed to authorization.WithRole: it checks the token against -// a role which is granted to nobody, so it rejects every request instead of accepting any -// authenticated one. -func zitadelAuthorization[T authorization.Ctx](interceptor *Interceptor[T], role string) gin.HandlerFunc { - if role == "" { - logrus.Debugf("No role required to access the API") - return interceptor.RequireAuthorization() - } - - logrus.Debugf("Requiring role %q to access the API", role) - return interceptor.RequireAuthorization(authorization.WithRole(role)) -} - -func newGinEngine(opts Options) *gin.Engine { +func newGinEngine(opts Options) (*gin.Engine, error) { r := gin.Default() r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) @@ -132,39 +123,69 @@ func newGinEngine(opts Options) *gin.Engine { apiPipeline := r.Group("/api/pipeline") - switch strings.ToLower(opts.Auth.Mode) { - case "oauth": - logrus.Debugf("Using OAuth authentication mode: %s", opts.Auth.Mode) + // auth authenticates a request against the identity provider. It stays nil when + // no authentication is configured. + var auth gin.HandlerFunc + // resolver reports the current permission behind an Udash API token. + var resolver RoleResolver = snapshotResolver{} + + ctx := context.Background() + + switch opts.Auth.Mode { + case ModeOIDC: + logrus.Debugf("Using OpenID Connect authentication mode") // Built once: the middleware caches the signing keys of the issuer, so building // it per request would refetch them on every call. - auth, err := checkJWT() + checked, err := checkJWT(opts.Auth) if err != nil { - slog.Error("jwt middleware could not initialize", "error", err) - os.Exit(1) + return nil, fmt.Errorf("jwt middleware could not initialize: %w", err) } + auth = checked - switch opts.Auth.Visibility { - case VisibilityPublic: - logrus.Debugf("API visibility set to public, no authentication required for read endpoints") - apiPipeline.Use(publicReadOnly(auth)) - case VisibilityPrivate: - logrus.Debugf("API visibility set to private, authentication required for all endpoints") - apiPipeline.Use(auth) + resolver, err = newRoleResolver(opts.Auth, nil) + if err != nil { + return nil, fmt.Errorf("role resolver could not initialize: %w", err) } - case "zitadel": - logrus.Debugf("Using ZITADEL authentication mode: %s", opts.Auth.Mode) - ctx := context.Background() + case ModeZitadel: + logrus.Debugf("Using ZITADEL authentication mode") authZ, err := authorization.New(ctx, zitadel.New(opts.Auth.Zitadel.Domain), oauth.DefaultAuthorization(opts.Auth.Zitadel.KeyFile)) if err != nil { - slog.Error("zitadel sdk could not initialize", "error", err) - os.Exit(1) + return nil, fmt.Errorf("zitadel sdk could not initialize: %w", err) + } + + zitadelInterceptor := NewZitadelGin(authZ, opts.Auth.Roles) + auth = zitadelInterceptor.RequireAuthorization() + + var roles zitadelUserRoles + if opts.Auth.Roles.Resolver == ResolverZitadel { + roles, err = newZitadelUserRoles(ctx, opts.Auth.Zitadel) + if err != nil { + return nil, fmt.Errorf("zitadel management client could not initialize: %w", err) + } + } + + resolver, err = newRoleResolver(opts.Auth, roles) + if err != nil { + return nil, fmt.Errorf("role resolver could not initialize: %w", err) } - zitadelInterceptor := NewZitadelGin(authZ) - auth := zitadelAuthorization(zitadelInterceptor, opts.Auth.Zitadel.Role) + case ModeNone, "": + logrus.Warningf("No authentication configured, every API endpoint is open") + + default: + // Never fail open: an unrecognised mode used to register no middleware at + // all, silently leaving every write endpoint unauthenticated. + return nil, fmt.Errorf("unknown authentication mode %q", opts.Auth.Mode) + } + + if auth != nil { + // An Udash API token is checked first and independently of the mode: Udash + // issues and validates those itself, so they work the same whichever + // identity provider is configured. + auth = udashTokenAuth(resolver, auth) switch opts.Auth.Visibility { case VisibilityPublic: @@ -174,6 +195,8 @@ func newGinEngine(opts Options) *gin.Engine { logrus.Debugf("API visibility set to private, authentication required for all endpoints") apiPipeline.Use(auth) } + + registerTokenRoutes(r, auth) } apiPipeline.GET("/labels", ListLabels) @@ -204,9 +227,16 @@ func newGinEngine(opts Options) *gin.Engine { apiPipeline.POST("/scms/search", SearchSCMs) } - apiPipeline.POST("/reports", CreatePipelineReport) - apiPipeline.PUT("/reports/:id", UpdatePipelineReport) - apiPipeline.DELETE("/reports/:id", DeletePipelineReport) + // Writing a report needs more than a valid token: the caller must be allowed to + // publish, and a token must have been granted the scope to do it. + write := []gin.HandlerFunc{} + if auth != nil { + write = append(write, requireScope(ScopeReportsWrite)) + } + + apiPipeline.POST("/reports", append(write, CreatePipelineReport)...) + apiPipeline.PUT("/reports/:id", append(write, UpdatePipelineReport)...) + apiPipeline.DELETE("/reports/:id", append(write, DeletePipelineReport)...) - return r + return r, nil } diff --git a/pkg/server/endpoints_test.go b/pkg/server/endpoints_test.go index a2375f41..49f13513 100644 --- a/pkg/server/endpoints_test.go +++ b/pkg/server/endpoints_test.go @@ -28,7 +28,8 @@ import ( ) func TestEndpoints(t *testing.T) { - eng := newGinEngine(Options{}) + eng, err := newGinEngine(Options{}) + require.NoError(t, err) srv := httptest.NewServer(eng) defer srv.Close() @@ -142,7 +143,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) resp := doGetRequest(t, srv, "/api/pipeline/reports") @@ -191,7 +192,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) report2ID, err = database.InsertReport(context.TODO(), reports.Report{ @@ -204,7 +205,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) resp := doGetRequest(t, srv, "/api/pipeline/reports?limit=1") @@ -257,7 +258,7 @@ func TestEndpoints(t *testing.T) { ID: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", }, }, - }) + }, database.Publisher{}) require.NoError(t, err) resp := doGetRequest(t, srv, "/api/pipeline/reports/"+reportID) @@ -518,7 +519,7 @@ func TestEndpoints(t *testing.T) { Result: pipelineResult, ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", PipelineID: "venom", - }) + }, database.Publisher{}) require.NoError(t, err) setReportTimestamp(t, id, now.AddDate(0, 0, dayOffset)) @@ -819,7 +820,7 @@ func TestEndpoints(t *testing.T) { Result: pipelineResult, ID: "1de1797bbc925e08e473178425b11eb16fc547291f4b45274da24c2b00e2afc3", PipelineID: "venom", - }) + }, database.Publisher{}) require.NoError(t, err) setReportTimestamp(t, id, at) @@ -896,7 +897,7 @@ func TestEndpoints(t *testing.T) { Actions: map[string]*reports.Action{ "default": {ID: "default", Link: actionURL}, }, - }) + }, database.Publisher{}) require.NoError(t, err) return id @@ -1099,7 +1100,7 @@ func TestEndpoints(t *testing.T) { for range 3 { id, err := database.InsertReport(ctx, reports.Report{ Name: "paginated", Result: "✔", ID: "paginated", PipelineID: "paginated", - }) + }, database.Publisher{}) require.NoError(t, err) t.Cleanup(func() { deleteReport(t, id) @@ -1170,7 +1171,7 @@ func TestEndpoints(t *testing.T) { Targets: map[string]*result.Target{ "tgt": {Config: map[string]any{"Kind": "file", "Spec": map[string]any{"file": "combined.txt"}}}, }, - }) + }, database.Publisher{}) require.NoError(t, err) t.Cleanup(func() { deleteReport(t, reportID) @@ -1229,7 +1230,7 @@ func TestEndpoints(t *testing.T) { reportID, err := database.InsertReport(ctx, reports.Report{ Name: "timerange", Result: "✔", ID: "timerange", PipelineID: "timerange", - }) + }, database.Publisher{}) require.NoError(t, err) t.Cleanup(func() { deleteReport(t, reportID) diff --git a/pkg/server/identity.go b/pkg/server/identity.go new file mode 100644 index 00000000..9d36a368 --- /dev/null +++ b/pkg/server/identity.go @@ -0,0 +1,217 @@ +package server + +import ( + "net/http" + "slices" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/updatecli/udash/pkg/database" +) + +// Permission is what an identity is allowed to do in Udash. +type Permission string + +const ( + // PermissionNone is granted to an unauthenticated request. + PermissionNone Permission = "" + // PermissionViewer may read pipeline reports. + PermissionViewer Permission = "viewer" + // PermissionPublisher may publish pipeline reports and create API tokens. + PermissionPublisher Permission = "publisher" + // PermissionAdmin may do anything, including managing other identities' tokens. + PermissionAdmin Permission = "admin" +) + +const ( + // ScopeReportsRead allows a token to read pipeline reports. + ScopeReportsRead = "reports:read" + // ScopeReportsWrite allows a token to publish pipeline reports. + ScopeReportsWrite = "reports:write" +) + +// principalContextKey is where the authenticated identity is stored on the request. +const principalContextKey = "udash.principal" + +// ParsePermission turns a configured string into a Permission. An unknown value +// yields PermissionNone, which IsValid rejects. +func ParsePermission(s string) Permission { + switch Permission(s) { + case PermissionViewer: + return PermissionViewer + case PermissionPublisher: + return PermissionPublisher + case PermissionAdmin: + return PermissionAdmin + } + return PermissionNone +} + +// IsValid reports whether the permission is one Udash knows about. +func (p Permission) IsValid() bool { + return p == PermissionViewer || p == PermissionPublisher || p == PermissionAdmin +} + +// rank orders permissions so they can be compared. Higher is more privileged. +func (p Permission) rank() int { + switch p { + case PermissionAdmin: + return 3 + case PermissionPublisher: + return 2 + case PermissionViewer: + return 1 + } + return 0 +} + +// AtLeast reports whether p grants everything other does. +func (p Permission) AtLeast(other Permission) bool { + return p.rank() >= other.rank() +} + +// Scopes returns the token scopes this permission is allowed to hand out. A token +// can never be granted more than the identity which created it, and never gets to +// manage tokens: a token must not be able to mint another one. +func (p Permission) Scopes() []string { + switch { + case p.AtLeast(PermissionPublisher): + return []string{ScopeReportsRead, ScopeReportsWrite} + case p.AtLeast(PermissionViewer): + return []string{ScopeReportsRead} + } + return nil +} + +// Principal is the identity behind a request. +type Principal struct { + // Subject is the identity provider subject. + Subject string + // Name is a human readable name for that identity, when the provider gives one. + Name string + // Permission is what that identity may do, after intersecting the identity + // provider roles with the scopes of the token in use. + Permission Permission + // TokenID is set only when the request authenticated with an Udash API token. + TokenID *uuid.UUID + // TokenName is the name of that token. + TokenName string + // Scopes is what that token may do. It is nil for an identity provider token, + // which is bounded by its Permission alone. + Scopes []string +} + +// IsToken reports whether the request authenticated with an Udash API token rather +// than with an identity provider token. +func (p Principal) IsToken() bool { + return p.TokenID != nil +} + +// HasScope reports whether the principal may perform the given action. +// +// An identity provider token carries no scopes, so it is bounded by its permission +// only: anything a publisher may do, it may do. +func (p Principal) HasScope(scope string) bool { + if !p.IsToken() { + switch scope { + case ScopeReportsWrite: + return p.Permission.AtLeast(PermissionPublisher) + case ScopeReportsRead: + return p.Permission.AtLeast(PermissionViewer) + } + return false + } + + return slices.Contains(p.Scopes, scope) +} + +// setPrincipal records the authenticated identity on the request. +func setPrincipal(c *gin.Context, p Principal) { + c.Set(principalContextKey, p) +} + +// principalFromContext returns the authenticated identity behind the request, if any. +func principalFromContext(c *gin.Context) (Principal, bool) { + value, ok := c.Get(principalContextKey) + if !ok { + return Principal{}, false + } + + principal, ok := value.(Principal) + return principal, ok +} + +// publisherFromContext describes who is publishing, for attribution. +// +// It yields an empty Publisher when the request is unauthenticated, which is the +// normal case on an instance running without authentication. +func publisherFromContext(c *gin.Context) database.Publisher { + principal, ok := principalFromContext(c) + if !ok || principal.Subject == "" { + return database.Publisher{} + } + + subject := principal.Subject + + return database.Publisher{ + Subject: &subject, + TokenID: principal.TokenID, + } +} + +// requirePermission aborts the request unless the caller is at least as privileged +// as the given permission. +func requirePermission(permission Permission) gin.HandlerFunc { + return func(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if !principal.Permission.AtLeast(permission) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{errMessageType: ErrInsufficientPermission}) + return + } + + c.Next() + } +} + +// requireScope aborts the request unless the caller may perform the given action. +func requireScope(scope string) gin.HandlerFunc { + return func(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if !principal.HasScope(scope) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{errMessageType: ErrInsufficientScope}) + return + } + + c.Next() + } +} + +// rejectTokenAuth aborts the request when it authenticated with an Udash API token. +// Minting a token must require an identity provider login, otherwise a leaked token +// could be used to issue fresh ones and outlive its own revocation. +func rejectTokenAuth() gin.HandlerFunc { + return func(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if principal.IsToken() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{errMessageType: ErrTokenCannotMintToken}) + return + } + + c.Next() + } +} diff --git a/pkg/server/jwt.go b/pkg/server/jwt.go index fb90550c..a521ee70 100644 --- a/pkg/server/jwt.go +++ b/pkg/server/jwt.go @@ -15,16 +15,11 @@ import ( "github.com/sirupsen/logrus" ) -var ( - // We want this struct to be filled in with - // our custom claims from the token. - customClaims = func() validator.CustomClaims { - return &CustomClaims{} - } - - // jwtOptions holds the JWT options - authOption = AuthOptions{} -) +// We want this struct to be filled in with +// our custom claims from the token. +var customClaims = func() validator.CustomClaims { + return &CustomClaims{} +} // parseIssuerURL turns a configured issuer into a URL, accepting it either as a bare host // ("example.eu.auth0.com") or as a full URL ("https://example.eu.auth0.com"). https is @@ -63,9 +58,9 @@ func parseIssuerURL(issuer string) (*url.URL, error) { // // A setup failure is reported rather than logged: carrying on would leave a nil validator // behind, which panics on the first request it is asked to authenticate. -func checkJWT() (gin.HandlerFunc, error) { +func checkJWT(opts AuthOptions) (gin.HandlerFunc, error) { - issuerURL, err := parseIssuerURL(authOption.Oauth.Issuer) + issuerURL, err := parseIssuerURL(opts.OIDC.Issuer) if err != nil { return nil, fmt.Errorf("parsing the issuer url: %w", err) } @@ -76,7 +71,7 @@ func checkJWT() (gin.HandlerFunc, error) { provider.KeyFunc, validator.RS256, issuerURL.String(), - authOption.Oauth.Audience, + opts.OIDC.Audience, validator.WithCustomClaims(customClaims), validator.WithAllowedClockSkew(30*time.Second), ) @@ -99,6 +94,7 @@ func checkJWT() (gin.HandlerFunc, error) { var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { encounteredError = false ctx.Request = r + setPrincipal(ctx, principalFromValidatedClaims(r, opts.Roles)) ctx.Next() } @@ -112,3 +108,30 @@ func checkJWT() (gin.HandlerFunc, error) { } }, nil } + +// principalFromValidatedClaims turns the claims the middleware validated into the +// identity the handlers work with. +func principalFromValidatedClaims(r *http.Request, roles RolesOptions) Principal { + validated, ok := r.Context().Value(jwtmiddleware.ContextKey{}).(*validator.ValidatedClaims) + if !ok || validated == nil { + return Principal{Permission: ParsePermission(roles.Default)} + } + + principal := Principal{ + Subject: validated.RegisteredClaims.Subject, + Permission: ParsePermission(roles.Default), + } + + claims, ok := validated.CustomClaims.(*CustomClaims) + if !ok || claims == nil { + return principal + } + + principal.Name = claims.Name + if principal.Name == "" { + principal.Name = claims.Username + } + principal.Permission = permissionFromRoles(rolesFromClaims(claims.All, roles.Claim), roles) + + return principal +} diff --git a/pkg/server/jwtClaim.go b/pkg/server/jwtClaim.go index 15c0e244..929e9f77 100644 --- a/pkg/server/jwtClaim.go +++ b/pkg/server/jwtClaim.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "errors" ) @@ -10,6 +11,27 @@ type CustomClaims struct { Name string `json:"name"` Username string `json:"username"` ShouldReject bool `json:"shouldReject,omitempty"` + + // All keeps every claim of the token, including the ones above. + // + // Which claim carries the identity provider roles is configuration, not + // something that can be named in a struct tag: Zitadel, Keycloak and Auth0 + // each use a different one. See RolesOptions.Claim. + All map[string]interface{} `json:"-"` +} + +// UnmarshalJSON decodes the named claims and keeps the raw ones alongside. +func (c *CustomClaims) UnmarshalJSON(data []byte) error { + // A local type avoids recursing back into this method. + type claims CustomClaims + + decoded := claims{} + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *c = CustomClaims(decoded) + + return json.Unmarshal(data, &c.All) } // Validate errors out if `ShouldReject` is true. diff --git a/pkg/server/option.go b/pkg/server/option.go index 8006c788..090a054e 100644 --- a/pkg/server/option.go +++ b/pkg/server/option.go @@ -5,6 +5,7 @@ type Options struct { Auth AuthOptions } -func (o *Options) Init() { - o.Auth.Init() +// Init fills in the defaults and reports what it cannot make sense of. +func (o *Options) Init() error { + return o.Auth.Init() } diff --git a/pkg/server/optionAuth.go b/pkg/server/optionAuth.go new file mode 100644 index 00000000..b2793382 --- /dev/null +++ b/pkg/server/optionAuth.go @@ -0,0 +1,225 @@ +package server + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +const ( + // VisibilityPublic indicates a public API + VisibilityPublic string = "public" + // VisibilityPrivate indicates a private API + VisibilityPrivate string = "private" + // visibilityDefault indicate Default visibility + VisibilityDefault = VisibilityPublic + // ModeZitadel indicates Zitadel authentication, validating tokens by introspection + ModeZitadel = "zitadel" + // ModeOIDC indicates generic OpenID Connect authentication, validating JWT + // access tokens locally against the issuer signing keys + ModeOIDC = "oidc" + // ModeNone indicates no authentication + ModeNone = "none" + + // DefaultRoleCacheTTL is how long a permission resolved from the identity + // provider is reused before being looked up again. + DefaultRoleCacheTTL = 60 * time.Second + + // ZitadelRolesClaim is the token claim Zitadel puts the project roles in. + ZitadelRolesClaim = "urn:zitadel:iam:org:project:roles" + + // ResolverZitadel resolves the permission behind an Udash API token by asking + // Zitadel for the current grants of the identity which created it. + ResolverZitadel = "zitadel" + // ResolverSnapshot trusts the permission recorded when the token was created. + ResolverSnapshot = "snapshot" +) + +// AuthOptions holds every authentication and authorization setting. +type AuthOptions struct { + // Mode selects how incoming tokens are validated. + // Accepted values are: "oidc", "zitadel", "none" + // Default to "none" + Mode string + // Zitadel holds Zitadel specific options + Zitadel ZitadelOptions + // OIDC holds generic OpenID Connect options + OIDC OIDCOptions + // Roles maps identity provider roles onto Udash permissions + Roles RolesOptions + // Visibility defines the visibility of the API + // Accepted values are: "public", "private" + // Default to "public" + Visibility string +} + +// ZitadelOptions defines Zitadel specific options +// for authentication +type ZitadelOptions struct { + // Domain is the Zitadel domain + // example: xxx.region.zitadel.cloud + Domain string + // KeyFile is the path to the service account key file + // example: /path/to/key.json + KeyFile string +} + +// OIDCOptions defines the settings of the generic OpenID Connect mode. It works +// with any provider issuing JWT access tokens, Zitadel included. +type OIDCOptions struct { + // The issuer of our token. + Issuer string + // The audience of our token. + Audience []string +} + +// RolesOptions describes how the roles carried by a token become Udash permissions. +type RolesOptions struct { + // Claim is the token claim holding the identity provider roles. Providers + // disagree both on the name and on the shape: Zitadel uses an object keyed by + // role name, Keycloak and Auth0 use an array of strings. Both are accepted. + Claim string + // Mapping lists, per Udash permission, the identity provider roles granting it. + Mapping map[string][]string + // Default is the permission granted to an authenticated identity matching no + // role at all. It deliberately defaults to the least privileged one. + Default string + // Resolver decides how the permission behind an Udash API token is resolved, + // since such a request carries no identity provider token to read roles from. + Resolver string + // CacheTTL is how long a resolved permission is reused before being looked up + // again. Without it a publish heavy pipeline would query the identity provider + // on every single report. + CacheTTL time.Duration +} + +// Init fills in the defaults and the environment variable fallbacks, and reports +// what it cannot make sense of. +// +// An error here must stop the server: carrying on with an unusable configuration +// leaves the API unauthenticated, which is the opposite of what was asked for. +func (a *AuthOptions) Init() error { + + if a.Mode == "" { + a.Mode = os.Getenv("UDASH_AUTH_MODE") + } + a.Mode = strings.ToLower(a.Mode) + + switch a.Visibility { + case VisibilityPublic: + logrus.Debugf("API visibility set to public") + case VisibilityPrivate: + logrus.Debugf("API visibility set to private") + case "": + logrus.Debugf("No API visibility set, defaulting to %q", VisibilityDefault) + a.Visibility = VisibilityDefault + default: + return fmt.Errorf("unknown API visibility %q, accepted values are: %q, %q", + a.Visibility, VisibilityPublic, VisibilityPrivate) + } + + switch a.Mode { + case ModeZitadel: + if a.Zitadel.Domain == "" { + a.Zitadel.Domain = os.Getenv("UDASH_AUTH_ZITADEL_DOMAIN") + } + if a.Zitadel.KeyFile == "" { + a.Zitadel.KeyFile = os.Getenv("UDASH_AUTH_ZITADEL_KEYFILE") + } + if a.Zitadel.Domain == "" { + return fmt.Errorf("authentication mode %q requires a Zitadel domain", ModeZitadel) + } + if a.Zitadel.KeyFile == "" { + return fmt.Errorf("authentication mode %q requires a Zitadel key file", ModeZitadel) + } + case ModeOIDC: + if a.OIDC.Issuer == "" { + a.OIDC.Issuer = os.Getenv("UDASH_AUTH_OIDC_ISSUER") + } + if len(a.OIDC.Audience) == 0 { + if audience := os.Getenv("UDASH_AUTH_OIDC_AUDIENCE"); audience != "" { + a.OIDC.Audience = []string{audience} + } + } + if a.OIDC.Issuer == "" { + return fmt.Errorf("authentication mode %q requires an issuer", ModeOIDC) + } + case ModeNone, "": + a.Mode = ModeNone + logrus.Warningf("No authentication configured, every API endpoint is open") + default: + return fmt.Errorf("unknown authentication mode %q, accepted values are: %q, %q, %q", + a.Mode, ModeOIDC, ModeZitadel, ModeNone) + } + + return a.Roles.init(a.Mode) +} + +func (r *RolesOptions) init(mode string) error { + if r.Claim == "" { + r.Claim = os.Getenv("UDASH_AUTH_ROLES_CLAIM") + } + if r.Default == "" { + r.Default = os.Getenv("UDASH_AUTH_ROLES_DEFAULT") + } + if r.Resolver == "" { + r.Resolver = os.Getenv("UDASH_AUTH_ROLES_RESOLVER") + } + + if r.Claim == "" && mode == ModeZitadel { + r.Claim = ZitadelRolesClaim + } + + if len(r.Mapping) == 0 { + r.Mapping = map[string][]string{ + string(PermissionAdmin): {"udash.admin"}, + string(PermissionPublisher): {"udash.publisher"}, + string(PermissionViewer): {"udash.viewer"}, + } + } + + for permission := range r.Mapping { + if !ParsePermission(permission).IsValid() { + return fmt.Errorf("unknown permission %q in the role mapping, accepted values are: %q, %q, %q", + permission, PermissionViewer, PermissionPublisher, PermissionAdmin) + } + } + + if r.Default == "" { + r.Default = string(PermissionViewer) + } + if !ParsePermission(r.Default).IsValid() { + return fmt.Errorf("unknown default permission %q, accepted values are: %q, %q, %q", + r.Default, PermissionViewer, PermissionPublisher, PermissionAdmin) + } + + if r.Resolver == "" { + r.Resolver = ResolverSnapshot + if mode == ModeZitadel { + r.Resolver = ResolverZitadel + } + } + switch r.Resolver { + case ResolverZitadel: + if mode != ModeZitadel { + return fmt.Errorf("role resolver %q requires the %q authentication mode", ResolverZitadel, ModeZitadel) + } + case ResolverSnapshot: + default: + return fmt.Errorf("unknown role resolver %q, accepted values are: %q, %q", + r.Resolver, ResolverZitadel, ResolverSnapshot) + } + + if r.CacheTTL == 0 { + r.CacheTTL = DefaultRoleCacheTTL + } + + if r.Claim == "" && mode != ModeNone { + logrus.Warningf("No role claim configured, every authenticated identity gets the %q permission", r.Default) + } + + return nil +} diff --git a/pkg/server/optionAuth_test.go b/pkg/server/optionAuth_test.go new file mode 100644 index 00000000..6c1b1715 --- /dev/null +++ b/pkg/server/optionAuth_test.go @@ -0,0 +1,138 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthOptionsInit(t *testing.T) { + t.Run("no mode defaults to none and public", func(t *testing.T) { + opts := AuthOptions{} + require.NoError(t, opts.Init()) + + assert.Equal(t, ModeNone, opts.Mode) + assert.Equal(t, VisibilityPublic, opts.Visibility) + assert.Equal(t, string(PermissionViewer), opts.Roles.Default) + assert.Equal(t, ResolverSnapshot, opts.Roles.Resolver) + assert.Equal(t, DefaultRoleCacheTTL, opts.Roles.CacheTTL) + }) + + t.Run("an unknown mode is rejected", func(t *testing.T) { + // Regression: an unrecognised mode used to be logged and then ignored, + // registering no middleware at all and leaving every write endpoint open. + opts := AuthOptions{Mode: "zitadelx"} + require.ErrorContains(t, opts.Init(), "unknown authentication mode") + }) + + t.Run("an unknown visibility is rejected", func(t *testing.T) { + opts := AuthOptions{Mode: ModeNone, Visibility: "sometimes"} + require.ErrorContains(t, opts.Init(), "unknown API visibility") + }) + + t.Run("the mode is case insensitive", func(t *testing.T) { + opts := AuthOptions{Mode: "OIDC", OIDC: OIDCOptions{Issuer: "https://example.com"}} + require.NoError(t, opts.Init()) + assert.Equal(t, ModeOIDC, opts.Mode) + }) + + t.Run("oidc requires an issuer", func(t *testing.T) { + opts := AuthOptions{Mode: ModeOIDC} + require.ErrorContains(t, opts.Init(), "requires an issuer") + }) + + t.Run("zitadel requires a domain and a key file", func(t *testing.T) { + require.ErrorContains(t, (&AuthOptions{Mode: ModeZitadel}).Init(), "requires a Zitadel domain") + + opts := AuthOptions{Mode: ModeZitadel, Zitadel: ZitadelOptions{Domain: "example.zitadel.cloud"}} + require.ErrorContains(t, opts.Init(), "requires a Zitadel key file") + }) + + t.Run("zitadel defaults the claim and the resolver", func(t *testing.T) { + opts := AuthOptions{ + Mode: ModeZitadel, + Zitadel: ZitadelOptions{Domain: "example.zitadel.cloud", KeyFile: "/tmp/key.json"}, + } + require.NoError(t, opts.Init()) + + assert.Equal(t, ZitadelRolesClaim, opts.Roles.Claim) + assert.Equal(t, ResolverZitadel, opts.Roles.Resolver) + }) + + t.Run("the zitadel resolver needs the zitadel mode", func(t *testing.T) { + opts := AuthOptions{ + Mode: ModeOIDC, + OIDC: OIDCOptions{Issuer: "https://example.com"}, + Roles: RolesOptions{Resolver: ResolverZitadel}, + } + require.ErrorContains(t, opts.Init(), "requires the \"zitadel\" authentication mode") + }) + + t.Run("an unknown permission in the mapping is rejected", func(t *testing.T) { + opts := AuthOptions{ + Mode: ModeNone, + Roles: RolesOptions{Mapping: map[string][]string{"superuser": {"udash.superuser"}}}, + } + require.ErrorContains(t, opts.Init(), "unknown permission") + }) + + t.Run("an unknown default permission is rejected", func(t *testing.T) { + opts := AuthOptions{Mode: ModeNone, Roles: RolesOptions{Default: "superuser"}} + require.ErrorContains(t, opts.Init(), "unknown default permission") + }) + + t.Run("environment variables are used as fallbacks", func(t *testing.T) { + t.Setenv("UDASH_AUTH_MODE", ModeOIDC) + t.Setenv("UDASH_AUTH_OIDC_ISSUER", "https://example.com") + t.Setenv("UDASH_AUTH_OIDC_AUDIENCE", "udash") + t.Setenv("UDASH_AUTH_ROLES_CLAIM", "realm_access.roles") + t.Setenv("UDASH_AUTH_ROLES_DEFAULT", string(PermissionPublisher)) + + opts := AuthOptions{} + require.NoError(t, opts.Init()) + + assert.Equal(t, ModeOIDC, opts.Mode) + assert.Equal(t, "https://example.com", opts.OIDC.Issuer) + assert.Equal(t, []string{"udash"}, opts.OIDC.Audience) + assert.Equal(t, "realm_access.roles", opts.Roles.Claim) + assert.Equal(t, string(PermissionPublisher), opts.Roles.Default) + }) + + t.Run("explicit values win over the environment", func(t *testing.T) { + t.Setenv("UDASH_AUTH_MODE", ModeZitadel) + t.Setenv("UDASH_AUTH_OIDC_ISSUER", "https://from-env.example.com") + + opts := AuthOptions{ + Mode: ModeOIDC, + OIDC: OIDCOptions{Issuer: "https://explicit.example.com"}, + Roles: RolesOptions{CacheTTL: 5 * time.Second}, + } + require.NoError(t, opts.Init()) + + assert.Equal(t, ModeOIDC, opts.Mode) + assert.Equal(t, "https://explicit.example.com", opts.OIDC.Issuer) + assert.Equal(t, 5*time.Second, opts.Roles.CacheTTL) + }) +} + +func TestNewGinEngineFailsClosed(t *testing.T) { + // An unusable configuration must stop the server rather than quietly serve an + // unauthenticated API. + _, err := newGinEngine(Options{Auth: AuthOptions{Mode: "zitadelx"}}) + require.ErrorContains(t, err, "unknown authentication mode") +} + +func TestNewGinEngineWithoutAuth(t *testing.T) { + // The default deployment has no authentication at all and must keep working. + engine, err := newGinEngine(Options{}) + require.NoError(t, err) + require.NotNil(t, engine) + + for _, route := range engine.Routes() { + assert.NotEqual(t, "/api/tokens", route.Path, + "the token endpoints must not exist when nobody can be authenticated") + assert.NotEqual(t, "/api/whoami", route.Path) + } +} diff --git a/pkg/server/optionOauth.go b/pkg/server/optionOauth.go deleted file mode 100644 index 7da752f9..00000000 --- a/pkg/server/optionOauth.go +++ /dev/null @@ -1,108 +0,0 @@ -package server - -import ( - "os" - - "github.com/sirupsen/logrus" -) - -const ( - // VisibilityPublic indicates a public API - VisibilityPublic string = "public" - // VisibilityPrivate indicates a private API - VisibilityPrivate string = "private" - // visibilityDefault indicate Default visibility - VisibilityDefault = VisibilityPublic - // ModeZitadel indicates Zitadel authentication - ModeZitadel = "zitadel" - // ModeOauth indicates Oauth authentication - ModeOauth = "oauth" - // ModeNone indicates no authentication - ModeNone = "none" -) - -/* - Code heavily inspired by https://github.com/auth0/go-jwt-middleware/tree/v2.1.0/examples/gin-example -*/ - -type AuthOptions struct { - // Mode enable auth0 authentication - // Accepted values are: "auth0", "zitadel", "none" - // Default to "none" - Mode string - // Zitadel holds Zitadel specific options - Zitadel ZitadelOptions - // Oauth holds Oauth specific options - Oauth OauthOptions - // Visibility defines the visibility of the API - // Accepted values are: "public", "private" - // Default to "public" - Visibility string -} - -// ZitadelOptions defines Zitadel specific options -// for authentication -type ZitadelOptions struct { - // Domain is the Zitadel domain - // example: xxx.region.zitadel.cloud - Domain string - // KeyFile is the path to the service account key file - // example: /path/to/key.json - KeyFile string - // Role is the required role to access the API - Role string -} - -type OauthOptions struct { - // The issuer of our token. - Issuer string - // The audience of our token. - Audience []string -} - -func (a *AuthOptions) Init() { - - if a.Mode == "" { - a.Mode = os.Getenv("UDASH_AUTH_MODE") - } - - switch a.Visibility { - case VisibilityPublic: - logrus.Debugf("API visibility set to public") - case VisibilityPrivate: - logrus.Debugf("API visibility set to private") - case "": - logrus.Debugf("No API visibility set, defaulting to %q", VisibilityDefault) - a.Visibility = VisibilityDefault - default: - logrus.Errorf("Unknown API visibility %q, accepted values are: %q, %q", - a.Visibility, - VisibilityPublic, - VisibilityPrivate, - ) - } - - switch a.Mode { - case ModeZitadel: - if a.Zitadel.Domain == "" { - a.Zitadel.Domain = os.Getenv("UDASH_AUTH_ZITADEL_DOMAIN") - } - if a.Zitadel.KeyFile == "" { - a.Zitadel.KeyFile = os.Getenv("UDASH_AUTH_ZITADEL_FILEKEY") - } - case ModeOauth: - if a.Oauth.Issuer == "" { - a.Oauth.Issuer = os.Getenv("UDASH_AUTH_OAUTH_ISSUER") - } - - if len(a.Oauth.Audience) == 0 { - a.Oauth.Audience = []string{os.Getenv("UDASH_AUTH_OAUTH_AUDIENCE")} - } - case ModeNone, "": - // - default: - logrus.Errorf("Unknown authentication mode %q, accepted values are: %q, %q, %q", a.Mode, ModeOauth, ModeZitadel, ModeNone) - } - - authOption = *a -} diff --git a/pkg/server/report_handlers.go b/pkg/server/report_handlers.go index 34117346..926b03ea 100644 --- a/pkg/server/report_handlers.go +++ b/pkg/server/report_handlers.go @@ -39,7 +39,7 @@ func CreatePipelineReport(c *gin.Context) { return } - newReportID, err := database.InsertReport(c, p) + newReportID, err := database.InsertReport(c, p, publisherFromContext(c)) if err != nil { logrus.Errorf("insert reports: %s", err) c.JSON( diff --git a/pkg/server/roles.go b/pkg/server/roles.go new file mode 100644 index 00000000..c54a9672 --- /dev/null +++ b/pkg/server/roles.go @@ -0,0 +1,216 @@ +package server + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// rolesFromClaims reads the identity provider roles out of a token's claims. +// +// Providers disagree on the shape of that claim. Zitadel uses an object keyed by +// role name, mapping to the organisations granting it: +// +// {"urn:zitadel:iam:org:project:roles": {"udash.admin": {"orgID": "org.domain"}}} +// +// Keycloak and Auth0 use an array of strings: +// +// {"realm_access": {"roles": ["udash.admin"]}} +// +// Both are accepted, and the claim name may be dotted to reach into nested objects. +func rolesFromClaims(claims map[string]interface{}, claim string) []string { + if claim == "" || len(claims) == 0 { + return nil + } + + value, ok := lookupClaim(claims, claim) + if !ok { + return nil + } + + switch typed := value.(type) { + case map[string]interface{}: + // Zitadel: the role names are the keys. + roles := make([]string, 0, len(typed)) + for role := range typed { + roles = append(roles, role) + } + return roles + case []interface{}: + roles := make([]string, 0, len(typed)) + for _, entry := range typed { + if role, ok := entry.(string); ok { + roles = append(roles, role) + } + } + return roles + case []string: + return typed + case string: + return []string{typed} + } + + return nil +} + +// lookupClaim finds a claim by name, first verbatim and then by walking a dotted +// path. Zitadel's claim names contain colons but no dots, while Keycloak nests its +// roles under "realm_access.roles", so the verbatim lookup has to come first. +func lookupClaim(claims map[string]interface{}, claim string) (interface{}, bool) { + if value, ok := claims[claim]; ok { + return value, true + } + + parts := strings.Split(claim, ".") + if len(parts) == 1 { + return nil, false + } + + var current interface{} = claims + for _, part := range parts { + object, ok := current.(map[string]interface{}) + if !ok { + return nil, false + } + current, ok = object[part] + if !ok { + return nil, false + } + } + + return current, true +} + +// permissionFromRoles maps identity provider roles onto the most privileged Udash +// permission they grant, falling back on the configured default. +func permissionFromRoles(roles []string, opts RolesOptions) Permission { + granted := ParsePermission(opts.Default) + + for permission, names := range opts.Mapping { + candidate := ParsePermission(permission) + if !candidate.IsValid() || granted.AtLeast(candidate) { + continue + } + + for _, name := range names { + for _, role := range roles { + if role == name { + granted = candidate + break + } + } + } + } + + return granted +} + +// RoleResolver reports what an identity may currently do, given only its subject. +// +// It exists for requests authenticating with an Udash API token: those carry no +// identity provider token, so there are no claims to read the roles from. +type RoleResolver interface { + // Resolve returns the current permission of the given subject. The recorded + // permission is what was granted when the token was created, and is what a + // resolver returns when it cannot do better. + Resolve(ctx context.Context, subject string, recorded Permission) (Permission, error) +} + +// snapshotResolver trusts the permission recorded when the token was created. +// +// It is the only option for providers without a way to look up a subject's roles. +// Revoking a role at the provider does not downgrade tokens created before, so +// offboarding has to delete the identity's tokens. +type snapshotResolver struct{} + +func (snapshotResolver) Resolve(_ context.Context, _ string, recorded Permission) (Permission, error) { + return recorded, nil +} + +// zitadelUserRoles lists the roles currently granted to a subject. +type zitadelUserRoles func(ctx context.Context, subject string) ([]string, error) + +// cachingResolver asks the identity provider for the current roles of a subject, +// caching the answer so a publish heavy pipeline does not query it per report. +type cachingResolver struct { + roles zitadelUserRoles + opts RolesOptions + + mu sync.Mutex + entries map[string]cacheEntry + // now is overridable so the cache can be tested without sleeping. + now func() time.Time +} + +type cacheEntry struct { + permission Permission + expiresAt time.Time +} + +func newCachingResolver(roles zitadelUserRoles, opts RolesOptions) *cachingResolver { + return &cachingResolver{ + roles: roles, + opts: opts, + entries: map[string]cacheEntry{}, + now: time.Now, + } +} + +func (r *cachingResolver) Resolve(ctx context.Context, subject string, recorded Permission) (Permission, error) { + if subject == "" { + return recorded, nil + } + + r.mu.Lock() + entry, ok := r.entries[subject] + r.mu.Unlock() + + if ok && r.now().Before(entry.expiresAt) { + return entry.permission, nil + } + + roles, err := r.roles(ctx, subject) + if err != nil { + // Falling back on the recorded permission keeps publishing working through a + // provider outage. It cannot escalate: the recorded permission was already + // granted once, and is itself bounded by the token's scopes. + logrus.Warningf("Could not resolve the roles of %q, using the permission recorded on the token: %s", subject, err) + return recorded, nil + } + + permission := permissionFromRoles(roles, r.opts) + + // A token never grants more than it was created with, even if its owner has + // been promoted since. + if !recorded.AtLeast(permission) { + permission = recorded + } + + r.mu.Lock() + r.entries[subject] = cacheEntry{ + permission: permission, + expiresAt: r.now().Add(r.opts.CacheTTL), + } + r.mu.Unlock() + + return permission, nil +} + +// newRoleResolver builds the resolver named by the configuration. +func newRoleResolver(opts AuthOptions, roles zitadelUserRoles) (RoleResolver, error) { + switch opts.Roles.Resolver { + case ResolverSnapshot: + return snapshotResolver{}, nil + case ResolverZitadel: + if roles == nil { + return nil, fmt.Errorf("role resolver %q needs a Zitadel client", ResolverZitadel) + } + return newCachingResolver(roles, opts.Roles), nil + } + + return nil, fmt.Errorf("unknown role resolver %q", opts.Roles.Resolver) +} diff --git a/pkg/server/roles_test.go b/pkg/server/roles_test.go new file mode 100644 index 00000000..0b630524 --- /dev/null +++ b/pkg/server/roles_test.go @@ -0,0 +1,211 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// defaultRolesOptions is the mapping Init falls back on. +func defaultRolesOptions(claim string) RolesOptions { + return RolesOptions{ + Claim: claim, + Mapping: map[string][]string{ + string(PermissionAdmin): {"udash.admin"}, + string(PermissionPublisher): {"udash.publisher"}, + string(PermissionViewer): {"udash.viewer"}, + }, + Default: string(PermissionViewer), + CacheTTL: time.Minute, + } +} + +func TestRolesFromClaims(t *testing.T) { + testCases := []struct { + name string + claims string + claim string + expected []string + }{ + { + name: "zitadel puts the role names in the keys of an object", + claim: ZitadelRolesClaim, + claims: `{"urn:zitadel:iam:org:project:roles":{"udash.admin":{"orgID":"org.example.com"}}}`, + expected: []string{"udash.admin"}, + }, + { + name: "keycloak nests an array of strings", + claim: "realm_access.roles", + claims: `{"realm_access":{"roles":["udash.publisher","offline_access"]}}`, + expected: []string{"udash.publisher", "offline_access"}, + }, + { + name: "auth0 uses a namespaced array", + claim: "https://udash/roles", + claims: `{"https://udash/roles":["udash.viewer"]}`, + expected: []string{"udash.viewer"}, + }, + { + name: "a single string is accepted", + claim: "role", + claims: `{"role":"udash.admin"}`, + expected: []string{"udash.admin"}, + }, + { + name: "a missing claim yields nothing", + claim: "nope", + claims: `{"realm_access":{"roles":["udash.admin"]}}`, + expected: []string{}, + }, + { + name: "an unconfigured claim yields nothing", + claim: "", + claims: `{"urn:zitadel:iam:org:project:roles":{"udash.admin":{}}}`, + expected: []string{}, + }, + { + name: "a dotted path stopping on a non object yields nothing", + claim: "realm_access.roles.deeper", + claims: `{"realm_access":{"roles":["udash.admin"]}}`, + expected: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + claims := map[string]interface{}{} + require.NoError(t, json.Unmarshal([]byte(tc.claims), &claims)) + + assert.ElementsMatch(t, tc.expected, rolesFromClaims(claims, tc.claim)) + }) + } +} + +func TestPermissionFromRoles(t *testing.T) { + opts := defaultRolesOptions(ZitadelRolesClaim) + + testCases := []struct { + name string + roles []string + expected Permission + }{ + {"no role falls back on the default", nil, PermissionViewer}, + {"an unrelated role falls back on the default", []string{"other"}, PermissionViewer}, + {"a mapped role is granted", []string{"udash.publisher"}, PermissionPublisher}, + {"the most privileged role wins", []string{"udash.viewer", "udash.admin", "udash.publisher"}, PermissionAdmin}, + {"order does not matter", []string{"udash.admin", "udash.viewer"}, PermissionAdmin}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, permissionFromRoles(tc.roles, opts)) + }) + } +} + +func TestPermissionRanking(t *testing.T) { + assert.True(t, PermissionAdmin.AtLeast(PermissionPublisher)) + assert.True(t, PermissionPublisher.AtLeast(PermissionViewer)) + assert.True(t, PermissionViewer.AtLeast(PermissionViewer)) + assert.False(t, PermissionViewer.AtLeast(PermissionPublisher)) + assert.False(t, PermissionNone.AtLeast(PermissionViewer)) + + // A viewer must not be able to hand out a token which publishes. + assert.NotContains(t, PermissionViewer.Scopes(), ScopeReportsWrite) + assert.Contains(t, PermissionPublisher.Scopes(), ScopeReportsWrite) + assert.Empty(t, PermissionNone.Scopes()) +} + +func TestSnapshotResolver(t *testing.T) { + got, err := snapshotResolver{}.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + assert.Equal(t, PermissionPublisher, got) +} + +func TestCachingResolver(t *testing.T) { + opts := defaultRolesOptions(ZitadelRolesClaim) + + t.Run("resolves from the identity provider and caches", func(t *testing.T) { + calls := 0 + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + calls++ + return []string{"udash.viewer"}, nil + }, opts) + + // The creator has been demoted since the token was made. + for range 3 { + got, err := resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + assert.Equal(t, PermissionViewer, got) + } + assert.Equal(t, 1, calls, "the answer must be cached") + }) + + t.Run("looks up again once the entry expired", func(t *testing.T) { + calls := 0 + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + calls++ + return []string{"udash.viewer"}, nil + }, opts) + + now := time.Now() + resolver.now = func() time.Time { return now } + + _, err := resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + + now = now.Add(2 * opts.CacheTTL) + + _, err = resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + + assert.Equal(t, 2, calls) + }) + + t.Run("never grants more than the token was created with", func(t *testing.T) { + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + return []string{"udash.admin"}, nil + }, opts) + + // The creator was promoted after making the token; the token must not + // silently gain the new privileges. + got, err := resolver.Resolve(context.Background(), "user-1", PermissionViewer) + require.NoError(t, err) + assert.Equal(t, PermissionViewer, got) + }) + + t.Run("falls back on the recorded permission when the provider is down", func(t *testing.T) { + resolver := newCachingResolver(func(context.Context, string) ([]string, error) { + return nil, errors.New("zitadel unreachable") + }, opts) + + // Publishing has to keep working through an outage, and this cannot + // escalate: the permission was granted once already. + got, err := resolver.Resolve(context.Background(), "user-1", PermissionPublisher) + require.NoError(t, err) + assert.Equal(t, PermissionPublisher, got) + }) +} + +func TestNewRoleResolver(t *testing.T) { + t.Run("snapshot needs no client", func(t *testing.T) { + resolver, err := newRoleResolver(AuthOptions{Roles: RolesOptions{Resolver: ResolverSnapshot}}, nil) + require.NoError(t, err) + assert.IsType(t, snapshotResolver{}, resolver) + }) + + t.Run("zitadel without a client is an error", func(t *testing.T) { + _, err := newRoleResolver(AuthOptions{Roles: RolesOptions{Resolver: ResolverZitadel}}, nil) + require.Error(t, err) + }) + + t.Run("an unknown resolver is an error", func(t *testing.T) { + _, err := newRoleResolver(AuthOptions{Roles: RolesOptions{Resolver: "nope"}}, nil) + require.Error(t, err) + }) +} diff --git a/pkg/server/token_handlers.go b/pkg/server/token_handlers.go new file mode 100644 index 00000000..3278b548 --- /dev/null +++ b/pkg/server/token_handlers.go @@ -0,0 +1,273 @@ +package server + +import ( + "errors" + "net/http" + "slices" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/updatecli/udash/pkg/database" + "github.com/updatecli/udash/pkg/model" +) + +// CreateTokenRequest is the body of a token creation request. +type CreateTokenRequest struct { + // Name is what the token is for, shown back in the token list. + Name string `json:"name" binding:"required"` + // Scopes is what the token may do. It defaults to publishing reports, and may + // never exceed what the identity creating it is allowed to do. + Scopes []string `json:"scopes,omitempty"` + // ExpiresAt is when the token stops working. Leave it out for a token which + // never expires, which is what an unattended pipeline needs. + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// CreateTokenResponse carries the newly created token. +type CreateTokenResponse struct { + model.APIToken + // Token is the credential itself. It is returned here once and never again: + // only its hash is stored. + Token string `json:"token"` +} + +// WhoamiResponse describes the identity behind the credential used. +type WhoamiResponse struct { + Subject string `json:"subject,omitempty"` + Name string `json:"name,omitempty"` + Permission string `json:"permission,omitempty"` + TokenName string `json:"tokenName,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +// registerTokenRoutes wires the API token endpoints. +// +// They live on their own group rather than on /api/pipeline: that group is left +// open for reads when the API is public, which must never apply here. +func registerTokenRoutes(r *gin.Engine, auth gin.HandlerFunc) { + tokens := r.Group("/api/tokens", auth) + + // Creating a token requires signing in with the identity provider. Letting a + // token mint another one would let a leaked token outlive its own revocation. + tokens.POST("", rejectTokenAuth(), requirePermission(PermissionPublisher), CreateAPIToken) + tokens.GET("", ListAPITokens) + tokens.DELETE("/:id", DeleteAPIToken) + tokens.DELETE("", requirePermission(PermissionAdmin), DeleteAPITokensBySubject) + + r.GET("/api/whoami", auth, Whoami) +} + +// CreateAPIToken issues a new API token. +// +// @Summary Create an API token +// @Description Issue a long lived token to authenticate against the Udash API. The token is returned once and cannot be recovered afterwards. +// @Tags Tokens +// @Accept json +// @Produce json +// @Param request body CreateTokenRequest true "token to create" +// @Success 201 {object} CreateTokenResponse +// @Failure 400 {object} DefaultResponseModel +// @Failure 401 {object} DefaultResponseModel +// @Failure 403 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens [post] +func CreateAPIToken(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + request := CreateTokenRequest{} + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, DefaultResponseModel{Err: ErrInvalidTokenRequest}) + return + } + + allowed := principal.Permission.Scopes() + + scopes := request.Scopes + if len(scopes) == 0 { + // Publishing reports is what a token is almost always created for. + scopes = []string{ScopeReportsWrite} + } + + // A token must never grant more than the identity creating it. + for _, scope := range scopes { + if !slices.Contains(allowed, scope) { + c.JSON(http.StatusForbidden, DefaultResponseModel{Err: ErrInsufficientScope}) + return + } + } + + if request.ExpiresAt != nil && request.ExpiresAt.Before(time.Now()) { + c.JSON(http.StatusBadRequest, DefaultResponseModel{Err: ErrInvalidTokenRequest}) + return + } + + token, hash, err := generateAPIToken() + if err != nil { + logrus.Errorf("generating an API token: %s", err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + created, err := database.InsertAPIToken( + c.Request.Context(), + request.Name, + principal.Subject, + string(principal.Permission), + scopes, + hash, + request.ExpiresAt, + ) + if err != nil { + logrus.Errorf("storing an API token: %s", err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + c.JSON(http.StatusCreated, CreateTokenResponse{APIToken: *created, Token: token}) +} + +// ListAPITokens returns the caller's API tokens. +// +// @Summary List API tokens +// @Description List the caller's API tokens. Administrators may list everybody's with all=true. The tokens themselves are never returned. +// @Tags Tokens +// @Produce json +// @Param all query bool false "list every identity's tokens, administrators only" +// @Success 200 {array} model.APIToken +// @Failure 401 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens [get] +func ListAPITokens(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + // Default to the caller's own tokens, so listing everybody's has to be asked + // for explicitly and is refused to anyone but an administrator. + subject := principal.Subject + if c.Query("all") == "true" { + if !principal.Permission.AtLeast(PermissionAdmin) { + c.JSON(http.StatusForbidden, DefaultResponseModel{Err: ErrInsufficientPermission}) + return + } + subject = "" + } + + tokens, err := database.ListAPITokens(c.Request.Context(), subject) + if err != nil { + logrus.Errorf("listing API tokens: %s", err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + c.JSON(http.StatusOK, tokens) +} + +// DeleteAPIToken revokes an API token. +// +// @Summary Revoke an API token +// @Description Revoke one of the caller's API tokens. Administrators may revoke anybody's. +// @Tags Tokens +// @Produce json +// @Param id path string true "token id" +// @Success 200 {object} DefaultResponseModel +// @Failure 401 {object} DefaultResponseModel +// @Failure 404 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens/{id} [delete] +func DeleteAPIToken(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, DefaultResponseModel{Err: ErrTokenNotFound}) + return + } + + // Restricting the delete to the caller's own subject is what stops one identity + // revoking another's tokens. An administrator is not restricted. + subject := principal.Subject + if principal.Permission.AtLeast(PermissionAdmin) { + subject = "" + } + + if err := database.DeleteAPIToken(c.Request.Context(), id, subject); err != nil { + if errors.Is(err, database.ErrAPITokenNotFound) { + c.JSON(http.StatusNotFound, DefaultResponseModel{Err: ErrTokenNotFound}) + return + } + logrus.Errorf("deleting API token %s: %s", id, err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + c.JSON(http.StatusOK, DefaultResponseModel{Message: "token successfully revoked"}) +} + +// DeleteAPITokensBySubject revokes every token of an identity. +// +// @Summary Revoke every token of an identity +// @Description Revoke all API tokens created by a given identity, which is what offboarding somebody needs. Administrators only. +// @Tags Tokens +// @Produce json +// @Param subject query string true "identity provider subject" +// @Success 200 {object} DefaultResponseModel +// @Failure 400 {object} DefaultResponseModel +// @Failure 403 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/tokens [delete] +func DeleteAPITokensBySubject(c *gin.Context) { + subject := c.Query("subject") + if subject == "" { + c.JSON(http.StatusBadRequest, DefaultResponseModel{Err: ErrInvalidTokenRequest}) + return + } + + deleted, err := database.DeleteAPITokensBySubject(c.Request.Context(), subject) + if err != nil { + logrus.Errorf("deleting the API tokens of %q: %s", subject, err) + c.JSON(http.StatusInternalServerError, DefaultResponseModel{Err: err.Error()}) + return + } + + logrus.Infof("Revoked %d API tokens of %q", deleted, subject) + c.JSON(http.StatusOK, DefaultResponseModel{Message: "tokens successfully revoked"}) +} + +// Whoami describes the identity behind the credential used. +// +// @Summary Describe the current identity +// @Description Return the identity, permission and token scopes behind the credential used. Updatecli calls it to validate a token at login time. +// @Tags Tokens +// @Produce json +// @Success 200 {object} WhoamiResponse +// @Failure 401 {object} DefaultResponseModel +// @Security BearerAuth +// @Router /api/whoami [get] +func Whoami(c *gin.Context) { + principal, ok := principalFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, DefaultResponseModel{Err: ErrUnauthenticated}) + return + } + + c.JSON(http.StatusOK, WhoamiResponse{ + Subject: principal.Subject, + Name: principal.Name, + Permission: string(principal.Permission), + TokenName: principal.TokenName, + Scopes: principal.Scopes, + }) +} diff --git a/pkg/server/token_middleware.go b/pkg/server/token_middleware.go new file mode 100644 index 00000000..7a60a549 --- /dev/null +++ b/pkg/server/token_middleware.go @@ -0,0 +1,127 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/updatecli/udash/pkg/database" + "github.com/updatecli/udash/pkg/model" +) + +// APITokenPrefix marks a bearer token as one Udash issued itself. +// +// The prefix is what lets the middleware tell an Udash token from an identity +// provider one without having to try both, and lets secret scanners recognise one +// if it ever leaks into a public repository. +const APITokenPrefix = "udash_pat_" + +// apiTokenBytes is how much entropy a token carries. +const apiTokenBytes = 32 + +// generateAPIToken returns a new token and the hash to store for it. +func generateAPIToken() (string, []byte, error) { + buffer := make([]byte, apiTokenBytes) + if _, err := rand.Read(buffer); err != nil { + return "", nil, err + } + + token := APITokenPrefix + base64.RawURLEncoding.EncodeToString(buffer) + + return token, hashAPIToken(token), nil +} + +// hashAPIToken returns what gets stored for a token. +// +// A plain sha256 is enough here, unlike for a password: the token is 32 random +// bytes, so there is no dictionary to run against it. +func hashAPIToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// bearerToken returns the credential presented by a request, if any. +func bearerToken(c *gin.Context) string { + header := c.GetHeader("Authorization") + if header == "" { + return "" + } + + if len(header) < 7 || !strings.EqualFold(header[:7], "bearer ") { + return "" + } + + return strings.TrimSpace(header[7:]) +} + +// udashTokenAuth authenticates requests presenting an Udash API token, and hands +// everything else to the identity provider middleware. +// +// It runs first and independently of the configured mode: Udash issues and +// validates these tokens itself, so they behave the same whichever provider is in +// use, and they keep working when an identity provider token would have expired. +func udashTokenAuth(resolver RoleResolver, next gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + token := bearerToken(c) + if !strings.HasPrefix(token, APITokenPrefix) { + next(c) + return + } + + stored, err := database.GetAPITokenByHash(c.Request.Context(), hashAPIToken(token)) + if err != nil { + if !errors.Is(err, database.ErrAPITokenNotFound) { + logrus.Errorf("looking up an API token: %s", err) + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + if stored.ExpiresAt != nil && stored.ExpiresAt.Before(time.Now()) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + + setPrincipal(c, principalFromToken(c.Request.Context(), resolver, stored)) + + // Best effort: the timestamp helps spot an unused or leaked token, it does + // not authorize anything, so a failure must not fail the request. + if err := database.TouchAPIToken(c.Request.Context(), stored.ID); err != nil { + logrus.Debugf("recording the use of token %s: %s", stored.ID, err) + } + + c.Next() + } +} + +// principalFromToken works out what a token may currently do. +// +// The permission recorded on the token is what its creator could do when it was +// issued. Asking the resolver lets a role revoked at the identity provider take +// effect without having to hunt down the tokens created before it. +func principalFromToken(ctx context.Context, resolver RoleResolver, token *model.APIToken) Principal { + recorded := ParsePermission(token.Permission) + + permission, err := resolver.Resolve(ctx, token.Subject, recorded) + if err != nil { + logrus.Warningf("resolving the permission of %q: %s", token.Subject, err) + permission = recorded + } + + id := token.ID + + return Principal{ + Subject: token.Subject, + Permission: permission, + TokenID: &id, + TokenName: token.Name, + Scopes: token.Scopes, + } +} diff --git a/pkg/server/token_test.go b/pkg/server/token_test.go new file mode 100644 index 00000000..d252ecbe --- /dev/null +++ b/pkg/server/token_test.go @@ -0,0 +1,329 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/updatecli/udash/pkg/database" + "github.com/updatecli/udash/test" + "github.com/updatecli/updatecli/pkg/core/reports" + "github.com/updatecli/updatecli/pkg/core/result" +) + +// fakeIdentityAuth stands in for the identity provider middleware, so the token +// endpoints can be tested without a live Zitadel. +func fakeIdentityAuth(principal Principal) gin.HandlerFunc { + return func(c *gin.Context) { + if c.GetHeader("Authorization") == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{errMessageType: ErrUnauthenticated}) + return + } + setPrincipal(c, principal) + c.Next() + } +} + +// tokenTestServer wires the token endpoints behind a stubbed identity, plus the +// report write route so scope enforcement can be checked end to end. +func tokenTestServer(t *testing.T, identity Principal) *httptest.Server { + t.Helper() + + gin.SetMode(gin.TestMode) + r := gin.New() + + auth := udashTokenAuth(snapshotResolver{}, fakeIdentityAuth(identity)) + registerTokenRoutes(r, auth) + + r.POST("/api/pipeline/reports", auth, requireScope(ScopeReportsWrite), CreatePipelineReport) + + server := httptest.NewServer(r) + t.Cleanup(server.Close) + + return server +} + +func doJSON(t *testing.T, method, url, bearer string, body any) (int, map[string]any) { + t.Helper() + + var reader io.Reader + if body != nil { + encoded, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(encoded) + } + + req, err := http.NewRequest(method, url, reader) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + decoded := map[string]any{} + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + if len(raw) > 0 && raw[0] == '{' { + require.NoError(t, json.Unmarshal(raw, &decoded)) + } + + return resp.StatusCode, decoded +} + +func TestAPITokens(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, database.Connect(database.Options{URI: dbURL})) + require.NoError(t, database.RunMigrationUp()) + + publisher := Principal{Subject: "user-publisher", Name: "Pat", Permission: PermissionPublisher} + + t.Run("lifecycle", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{ + "name": "ci", + }) + require.Equal(t, http.StatusCreated, status) + + token, _ := created["token"].(string) + require.NotEmpty(t, token) + assert.True(t, len(token) > len(APITokenPrefix), "the token must carry the prefix and some entropy") + assert.Contains(t, token, APITokenPrefix) + assert.Nil(t, created["expires_at"], "a token created without an expiry never expires") + + // The token authenticates on its own, with no identity provider involved. + status, who := doJSON(t, http.MethodGet, srv.URL+"/api/whoami", token, nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, "user-publisher", who["subject"]) + assert.Equal(t, "ci", who["tokenName"]) + + // And it may publish. + status, _ = doJSON(t, http.MethodPost, srv.URL+"/api/pipeline/reports", token, map[string]any{ + "Name": "ci: bump something", "ID": "abc", "PipelineID": "p", + }) + assert.Equal(t, http.StatusCreated, status) + + id, _ := created["id"].(string) + require.NotEmpty(t, id) + + status, _ = doJSON(t, http.MethodDelete, srv.URL+"/api/tokens/"+id, "session", nil) + require.Equal(t, http.StatusOK, status) + + // Once revoked it stops working. + status, _ = doJSON(t, http.MethodGet, srv.URL+"/api/whoami", token, nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) + + t.Run("a token cannot mint another token", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "ci"}) + require.Equal(t, http.StatusCreated, status) + token := created["token"].(string) + + // Otherwise a leaked token could issue fresh ones and outlive its revocation. + status, _ = doJSON(t, http.MethodPost, srv.URL+"/api/tokens", token, map[string]any{"name": "sneaky"}) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("a viewer cannot create a token", func(t *testing.T) { + srv := tokenTestServer(t, Principal{Subject: "user-viewer", Permission: PermissionViewer}) + + // This is what stops everybody who can sign in from minting a publishing token. + status, _ := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "nope"}) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("a token cannot be granted more than its creator", func(t *testing.T) { + srv := tokenTestServer(t, Principal{Subject: "user-viewer-2", Permission: PermissionViewer}) + + status, _ := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{ + "name": "escalate", + "scopes": []string{ScopeReportsWrite}, + }) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("a read only token cannot publish", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{ + "name": "read-only", + "scopes": []string{ScopeReportsRead}, + }) + require.Equal(t, http.StatusCreated, status) + token := created["token"].(string) + + status, _ = doJSON(t, http.MethodPost, srv.URL+"/api/pipeline/reports", token, map[string]any{ + "Name": "nope", "ID": "def", "PipelineID": "p", + }) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("an expired token is rejected", func(t *testing.T) { + expired := time.Now().Add(-time.Hour) + token, hash, err := generateAPIToken() + require.NoError(t, err) + + _, err = database.InsertAPIToken(ctx, "expired", "user-publisher", + string(PermissionPublisher), []string{ScopeReportsWrite}, hash, &expired) + require.NoError(t, err) + + srv := tokenTestServer(t, publisher) + status, _ := doJSON(t, http.MethodGet, srv.URL+"/api/whoami", token, nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) + + t.Run("an unknown token is rejected", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, _ := doJSON(t, http.MethodGet, srv.URL+"/api/whoami", APITokenPrefix+"nonexistent", nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) + + t.Run("one identity cannot revoke another's token", func(t *testing.T) { + owner := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, owner.URL+"/api/tokens", "session", map[string]any{"name": "mine"}) + require.Equal(t, http.StatusCreated, status) + id := created["id"].(string) + + other := tokenTestServer(t, Principal{Subject: "somebody-else", Permission: PermissionPublisher}) + status, _ = doJSON(t, http.MethodDelete, other.URL+"/api/tokens/"+id, "session", nil) + assert.Equal(t, http.StatusNotFound, status) + + // An administrator may. + admin := tokenTestServer(t, Principal{Subject: "an-admin", Permission: PermissionAdmin}) + status, _ = doJSON(t, http.MethodDelete, admin.URL+"/api/tokens/"+id, "session", nil) + assert.Equal(t, http.StatusOK, status) + }) + + t.Run("listing is scoped to the caller unless they are an administrator", func(t *testing.T) { + srv := tokenTestServer(t, Principal{Subject: "user-lister", Permission: PermissionPublisher}) + + status, _ := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "mine"}) + require.Equal(t, http.StatusCreated, status) + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/tokens", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer session") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + listed := []map[string]any{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&listed)) + require.NotEmpty(t, listed) + for _, entry := range listed { + assert.Equal(t, "user-lister", entry["subject"]) + assert.NotContains(t, entry, "token", "the secret must never be listed") + } + + // Asking for everybody's is refused to a non administrator. + status, _ = doJSON(t, http.MethodGet, srv.URL+"/api/tokens?all=true", "session", nil) + assert.Equal(t, http.StatusForbidden, status) + }) + + t.Run("unauthenticated requests are refused", func(t *testing.T) { + srv := tokenTestServer(t, publisher) + + status, _ := doJSON(t, http.MethodGet, srv.URL+"/api/tokens", "", nil) + assert.Equal(t, http.StatusUnauthorized, status) + }) +} + +func TestReportAttribution(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, database.Connect(database.Options{URI: dbURL})) + require.NoError(t, database.RunMigrationUp()) + + publisher := Principal{Subject: "user-publisher", Permission: PermissionPublisher} + srv := tokenTestServer(t, publisher) + + status, created := doJSON(t, http.MethodPost, srv.URL+"/api/tokens", "session", map[string]any{"name": "ci"}) + require.Equal(t, http.StatusCreated, status) + token := created["token"].(string) + tokenID := created["id"].(string) + + status, published := doJSON(t, http.MethodPost, srv.URL+"/api/pipeline/reports", token, map[string]any{ + "Name": "ci: attributed", "ID": "attributed", "PipelineID": "p", + }) + require.Equal(t, http.StatusCreated, status) + + reportID, _ := published["reportid"].(string) + require.NotEmpty(t, reportID) + + var subject, storedTokenID *string + require.NoError(t, database.DB.QueryRow(ctx, + "SELECT created_by_subject, created_by_token_id::text FROM pipelineReports WHERE id = $1", + reportID, + ).Scan(&subject, &storedTokenID)) + + require.NotNil(t, subject) + assert.Equal(t, "user-publisher", *subject) + require.NotNil(t, storedTokenID) + assert.Equal(t, tokenID, *storedTokenID) +} + +func TestReportAttributionWithoutAuth(t *testing.T) { + ctx := context.Background() + + postgresContainer, err := test.SetupDatabase(t, ctx) + require.NoError(t, err) + + dbURL, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + require.NoError(t, database.Connect(database.Options{URI: dbURL})) + require.NoError(t, database.RunMigrationUp()) + + // An instance running without authentication has nobody to attribute to, and + // must keep publishing regardless. + reportID, err := database.InsertReport(ctx, anonymousReport(), database.Publisher{}) + require.NoError(t, err) + + var subject, tokenID *string + require.NoError(t, database.DB.QueryRow(ctx, + "SELECT created_by_subject, created_by_token_id::text FROM pipelineReports WHERE id = $1", + reportID, + ).Scan(&subject, &tokenID)) + + assert.Nil(t, subject) + assert.Nil(t, tokenID) +} + +// anonymousReport is a minimal report, for the attribution tests. +func anonymousReport() reports.Report { + return reports.Report{ + Name: "ci: anonymous", + Result: result.SUCCESS, + ID: "anonymous", + PipelineID: "p", + } +} diff --git a/pkg/server/var.go b/pkg/server/var.go index fc3d6255..d2e029ba 100644 --- a/pkg/server/var.go +++ b/pkg/server/var.go @@ -52,6 +52,23 @@ const ( ErrTooManyBuckets = "requested time range and granularity produce too many buckets" ErrInvalidJWT = "JWT is invalid" + // ErrUnauthenticated is returned when a request carries no usable credential. + ErrUnauthenticated = "authentication required" + // ErrInsufficientPermission is returned when the caller is authenticated but not + // privileged enough for the endpoint. + ErrInsufficientPermission = "insufficient permission" + // ErrInsufficientScope is returned when the token used is not allowed to perform + // the requested action, even though the identity behind it would be. + ErrInsufficientScope = "token is not allowed to perform this action" + // ErrTokenCannotMintToken is returned when an API token is used to create another + // one, which must require an identity provider login. + ErrTokenCannotMintToken = "creating a token requires signing in, it cannot be done with a token" + // ErrTokenNotFound is returned when the requested API token does not exist, or + // belongs to somebody else. + ErrTokenNotFound = "token not found" + // ErrInvalidTokenRequest is returned when a token creation request is malformed. + ErrInvalidTokenRequest = "invalid token request" + // summaryMetricResult counts the pipeline reports per Updatecli result. It is the // only metric supported by the reports summary so far. summaryMetricResult = "result" diff --git a/pkg/server/zitadel-gin.go b/pkg/server/zitadel-gin.go index d11602ec..e2d554eb 100644 --- a/pkg/server/zitadel-gin.go +++ b/pkg/server/zitadel-gin.go @@ -7,15 +7,19 @@ import ( "github.com/gin-gonic/gin" "github.com/zitadel/zitadel-go/v3/pkg/authorization" + "github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth" ) type Interceptor[T authorization.Ctx] struct { authorizer *authorization.Authorizer[T] + // roles describes how the claims of a token become an Udash permission. + roles RolesOptions } -func NewZitadelGin[T authorization.Ctx](authorizer *authorization.Authorizer[T]) *Interceptor[T] { +func NewZitadelGin[T authorization.Ctx](authorizer *authorization.Authorizer[T], roles RolesOptions) *Interceptor[T] { return &Interceptor[T]{ authorizer: authorizer, + roles: roles, } } @@ -33,10 +37,34 @@ func (i *Interceptor[T]) RequireAuthorization(options ...authorization.CheckOpti return } c.Request = c.Request.WithContext(authorization.WithAuthContext(c.Request.Context(), authCtx)) + setPrincipal(c, i.principal(authCtx)) c.Next() } } +// principal turns the introspected token into the identity the handlers work with. +func (i *Interceptor[T]) principal(authCtx T) Principal { + principal := Principal{ + Subject: authCtx.UserID(), + Permission: ParsePermission(i.roles.Default), + } + + // The introspection response carries the claims, but only the concrete type + // exposes them; authorization.Ctx deliberately does not. + introspection, ok := any(authCtx).(*oauth.IntrospectionContext) + if !ok || introspection == nil { + return principal + } + + principal.Name = introspection.Username + principal.Permission = permissionFromRoles( + rolesFromClaims(introspection.Claims, i.roles.Claim), + i.roles, + ) + + return principal +} + func (i *Interceptor[T]) Context(ctx context.Context) T { return authorization.Context[T](ctx) } diff --git a/pkg/server/zitadel-roles.go b/pkg/server/zitadel-roles.go new file mode 100644 index 00000000..26a98ccd --- /dev/null +++ b/pkg/server/zitadel-roles.go @@ -0,0 +1,54 @@ +package server + +import ( + "context" + "fmt" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "github.com/zitadel/zitadel-go/v3/pkg/client" + "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/management" + "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user" + "github.com/zitadel/zitadel-go/v3/pkg/zitadel" +) + +// newZitadelUserRoles builds a lookup of the roles currently granted to a subject. +// +// A request authenticating with an Udash API token carries no Zitadel token, so +// there are no claims to read the roles from and they have to be asked for. The +// service user behind the key file needs permission to read user grants in the +// organisation, otherwise every lookup fails and the resolver falls back on the +// permission recorded when the token was created. +func newZitadelUserRoles(ctx context.Context, opts ZitadelOptions) (zitadelUserRoles, error) { + api, err := client.New(ctx, zitadel.New(opts.Domain), + client.WithAuth(client.DefaultServiceUserAuthentication( + opts.KeyFile, + oidc.ScopeOpenID, + client.ScopeZitadelAPI(), + )), + ) + if err != nil { + return nil, fmt.Errorf("connecting to Zitadel: %w", err) + } + + return func(ctx context.Context, subject string) ([]string, error) { + resp, err := api.ManagementService().ListUserGrants(ctx, &management.ListUserGrantRequest{ + Queries: []*user.UserGrantQuery{ + { + Query: &user.UserGrantQuery_UserIdQuery{ + UserIdQuery: &user.UserGrantUserIDQuery{UserId: subject}, + }, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("listing the grants of %q: %w", subject, err) + } + + roles := []string{} + for _, grant := range resp.GetResult() { + roles = append(roles, grant.GetRoleKeys()...) + } + + return roles, nil + }, nil +}