Compare commits

...

1 Commits

Author SHA1 Message Date
Thomas Kosiewski 42451c13ec feat: add allow_list field to API key responses for resource scoping
Add allow_list field to API key data structures and ensure proper
JSON serialization across backend and frontend. Initialize with 
default wildcard entry (*:*) for backward compatibility with
existing API keys that don't have explicit resource restrictions.

Fixes #19854
2025-10-23 19:04:32 +02:00
14 changed files with 162 additions and 47 deletions
+6
View File
@@ -11917,6 +11917,12 @@ const docTemplate = `{
"user_id"
],
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"created_at": {
"type": "string",
"format": "date-time"
+6
View File
@@ -10613,6 +10613,12 @@
"user_id"
],
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"created_at": {
"type": "string",
"format": "date-time"
+6
View File
@@ -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())
})
}
}
+1
View File
@@ -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
+7
View File
@@ -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
+2 -1
View File
@@ -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.';
@@ -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;
@@ -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);
+1
View File
@@ -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),
}
}
+12 -11
View File
@@ -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.
+20 -13
View File
@@ -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
+85 -22
View File
@@ -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",
+1
View File
@@ -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
+2
View File
@@ -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",