diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d3ad637f7d..b4110e3a4c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11894,6 +11894,12 @@ const docTemplate = `{ "user_id" ], "properties": { + "allow_list": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIAllowListTarget" + } + }, "created_at": { "type": "string", "format": "date-time" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 21468a92f6..5d0f5de4e4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10590,6 +10590,12 @@ "user_id" ], "properties": { + "allow_list": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIAllowListTarget" + } + }, "created_at": { "type": "string", "format": "date-time" diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index f980706d6e..65feb1c9cb 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -51,6 +51,8 @@ func TestTokenCRUD(t *testing.T) { require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*6)) require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*8)) require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope) + require.Len(t, keys[0].AllowList, 1) + require.Equal(t, "*:*", keys[0].AllowList[0].String()) // no update @@ -86,6 +88,8 @@ func TestTokenScoped(t *testing.T) { require.EqualValues(t, len(keys), 1) require.Contains(t, res.Key, keys[0].ID) require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect) + require.Len(t, keys[0].AllowList, 1) + require.Equal(t, "*:*", keys[0].AllowList[0].String()) } // Ensure backward-compat: when a token is created using the legacy singular @@ -132,6 +136,8 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) { require.Len(t, keys, 1) require.Equal(t, tc.scope, keys[0].Scope) require.ElementsMatch(t, keys[0].Scopes, tc.scopes) + require.Len(t, keys[0].AllowList, 1) + require.Equal(t, "*:*", keys[0].AllowList[0].String()) }) } } diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index e80307ffc4..d84802b988 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -6,6 +6,7 @@ type CheckConstraint string // CheckConstraint enums. const ( + CheckAPIKeysAllowListNotEmpty CheckConstraint = "api_keys_allow_list_not_empty" // api_keys CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index a58d7967e6..4c1e3da1a4 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -51,6 +51,13 @@ func ListLazy[F any, T any](convert func(F) T) func(list []F) []T { } } +func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget { + return codersdk.APIAllowListTarget{ + Type: codersdk.RBACResource(entry.Type), + ID: entry.ID, + } +} + type ExternalAuthMeta struct { Authenticated bool ValidateError string diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index de55f8117f..17ba8442f4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1126,7 +1126,8 @@ CREATE TABLE api_keys ( ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL, token_name text DEFAULT ''::text NOT NULL, scopes api_key_scope[] NOT NULL, - allow_list text[] NOT NULL + allow_list text[] NOT NULL, + CONSTRAINT api_keys_allow_list_not_empty CHECK ((array_length(allow_list, 1) > 0)) ); COMMENT ON COLUMN api_keys.hashed_secret IS 'hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code.'; diff --git a/coderd/database/migrations/000388_api_key_allow_list_constraint.down.sql b/coderd/database/migrations/000388_api_key_allow_list_constraint.down.sql new file mode 100644 index 0000000000..aa6aa87f10 --- /dev/null +++ b/coderd/database/migrations/000388_api_key_allow_list_constraint.down.sql @@ -0,0 +1,3 @@ +-- Drop all CHECK constraints added in the up migration +ALTER TABLE api_keys +DROP CONSTRAINT api_keys_allow_list_not_empty; diff --git a/coderd/database/migrations/000388_api_key_allow_list_constraint.up.sql b/coderd/database/migrations/000388_api_key_allow_list_constraint.up.sql new file mode 100644 index 0000000000..6dc46b522b --- /dev/null +++ b/coderd/database/migrations/000388_api_key_allow_list_constraint.up.sql @@ -0,0 +1,10 @@ +-- Defensively update any API keys with empty allow_list to have default '*:*' +-- This ensures all existing keys have at least one entry before adding the constraint +UPDATE api_keys +SET allow_list = ARRAY['*:*'] +WHERE allow_list = ARRAY[]::text[] OR array_length(allow_list, 1) IS NULL; + +-- Add CHECK constraint to ensure allow_list array is never empty +ALTER TABLE api_keys +ADD CONSTRAINT api_keys_allow_list_not_empty +CHECK (array_length(allow_list, 1) > 0); diff --git a/coderd/users.go b/coderd/users.go index e24790e745..30fa7bf7ca 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1608,5 +1608,6 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { Scopes: scopes, LifetimeSeconds: k.LifetimeSeconds, TokenName: k.TokenName, + AllowList: db2sdk.List(k.AllowList, db2sdk.APIAllowListTarget), } } diff --git a/codersdk/apikey.go b/codersdk/apikey.go index ff5d749151..a5b622c73a 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -12,17 +12,18 @@ import ( // APIKey: do not ever return the HashedSecret type APIKey struct { - ID string `json:"id" validate:"required"` - UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` - LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"` - ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"` - CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` - LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` - Scope APIKeyScope `json:"scope" enums:"all,application_connect"` // Deprecated: use Scopes instead. - Scopes []APIKeyScope `json:"scopes"` - TokenName string `json:"token_name" validate:"required"` - LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` + ID string `json:"id" validate:"required"` + UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` + LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"` + ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"` + CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` + LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` + Scope APIKeyScope `json:"scope" enums:"all,application_connect"` // Deprecated: use Scopes instead. + Scopes []APIKeyScope `json:"scopes"` + TokenName string `json:"token_name" validate:"required"` + LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` + AllowList []APIAllowListTarget `json:"allow_list"` } // LoginType is the type of login used to create the API key. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 88d295d53e..5688fb5972 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -744,6 +744,12 @@ ```json { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", @@ -762,19 +768,20 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|-------------------------------------------------------|----------|--------------|---------------------------------| -| `created_at` | string | true | | | -| `expires_at` | string | true | | | -| `id` | string | true | | | -| `last_used` | string | true | | | -| `lifetime_seconds` | integer | true | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | -| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | -| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | -| `token_name` | string | true | | | -| `updated_at` | string | true | | | -| `user_id` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|---------------------------------------------------------------------|----------|--------------|---------------------------------| +| `allow_list` | array of [codersdk.APIAllowListTarget](#codersdkapiallowlisttarget) | false | | | +| `created_at` | string | true | | | +| `expires_at` | string | true | | | +| `id` | string | true | | | +| `last_used` | string | true | | | +| `lifetime_seconds` | integer | true | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | +| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | +| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| `token_name` | string | true | | | +| `updated_at` | string | true | | | +| `user_id` | string | true | | | #### Enumerated Values diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 0cfdd07c74..857d619398 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -757,6 +757,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ ```json [ { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", @@ -784,31 +790,76 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------------|--------------------------------------------------------|----------|--------------|---------------------------------| -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | true | | | -| `» expires_at` | string(date-time) | true | | | -| `» id` | string | true | | | -| `» last_used` | string(date-time) | true | | | -| `» lifetime_seconds` | integer | true | | | -| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | -| `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | -| `» scopes` | array | false | | | -| `» token_name` | string | true | | | -| `» updated_at` | string(date-time) | true | | | -| `» user_id` | string(uuid) | true | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|----------------------------------------------------------|----------|--------------|---------------------------------| +| `[array item]` | array | false | | | +| `» allow_list` | array | false | | | +| `»» id` | string | false | | | +| `»» type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» created_at` | string(date-time) | true | | | +| `» expires_at` | string(date-time) | true | | | +| `» id` | string | true | | | +| `» last_used` | string(date-time) | true | | | +| `» lifetime_seconds` | integer | true | | | +| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | +| `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | +| `» scopes` | array | false | | | +| `» token_name` | string | true | | | +| `» updated_at` | string(date-time) | true | | | +| `» user_id` | string(uuid) | true | | | #### Enumerated Values -| Property | Value | -|--------------|-----------------------| -| `login_type` | `password` | -| `login_type` | `github` | -| `login_type` | `oidc` | -| `login_type` | `token` | -| `scope` | `all` | -| `scope` | `application_connect` | +| Property | Value | +|--------------|------------------------------------| +| `type` | `*` | +| `type` | `aibridge_interception` | +| `type` | `api_key` | +| `type` | `assign_org_role` | +| `type` | `assign_role` | +| `type` | `audit_log` | +| `type` | `connection_log` | +| `type` | `crypto_key` | +| `type` | `debug_info` | +| `type` | `deployment_config` | +| `type` | `deployment_stats` | +| `type` | `file` | +| `type` | `group` | +| `type` | `group_member` | +| `type` | `idpsync_settings` | +| `type` | `inbox_notification` | +| `type` | `license` | +| `type` | `notification_message` | +| `type` | `notification_preference` | +| `type` | `notification_template` | +| `type` | `oauth2_app` | +| `type` | `oauth2_app_code_token` | +| `type` | `oauth2_app_secret` | +| `type` | `organization` | +| `type` | `organization_member` | +| `type` | `prebuilt_workspace` | +| `type` | `provisioner_daemon` | +| `type` | `provisioner_jobs` | +| `type` | `replicas` | +| `type` | `system` | +| `type` | `tailnet_coordinator` | +| `type` | `task` | +| `type` | `template` | +| `type` | `usage_event` | +| `type` | `user` | +| `type` | `user_secret` | +| `type` | `webpush_subscription` | +| `type` | `workspace` | +| `type` | `workspace_agent_devcontainers` | +| `type` | `workspace_agent_resource_monitor` | +| `type` | `workspace_dormant` | +| `type` | `workspace_proxy` | +| `login_type` | `password` | +| `login_type` | `github` | +| `login_type` | `oidc` | +| `login_type` | `token` | +| `scope` | `all` | +| `scope` | `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -896,6 +947,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \ ```json { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", @@ -946,6 +1003,12 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ ```json { + "allow_list": [ + { + "id": "string", + "type": "*" + } + ], "created_at": "2019-08-24T14:15:22Z", "expires_at": "2019-08-24T14:15:22Z", "id": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cee71ca6a9..a3bfcbbed4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -141,6 +141,7 @@ export interface APIKey { readonly scopes: readonly APIKeyScope[]; readonly token_name: string; readonly lifetime_seconds: number; + readonly allow_list: readonly APIAllowListTarget[]; } // From codersdk/apikey.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f682247683..c0f11f2f13 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -85,6 +85,7 @@ export const MockToken: TypesGen.APIKeyWithOwner = { login_type: "token", scope: "all", scopes: ["coder:all"], + allow_list: [{ type: "*", id: "*" }], lifetime_seconds: 2592000, token_name: "token-one", username: "admin", @@ -102,6 +103,7 @@ export const MockTokens: TypesGen.APIKeyWithOwner[] = [ login_type: "token", scope: "all", scopes: ["coder:all"], + allow_list: [{ type: "*", id: "*" }], lifetime_seconds: 2592000, token_name: "token-two", username: "admin",