From 1b1b1db7727742b903d283ff121f975a29af2f92 Mon Sep 17 00:00:00 2001 From: "agent-kanban[bot]" <295243365+agent-kanban[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:08:44 -0400 Subject: [PATCH] feat: add workspace agent API keys (#538) Enforce owner/admin management, terminal expired/revoked lifecycle, explicit workspace scopes, current membership rechecks, and authenticated management UI. --- cmd/internal/openapi/client.gen.go | 1066 ++++++++++++++++- docs/design/agent-authentication.md | 10 +- docs/roadmap/v2.9.md | 4 + server/adapters/repos/api-key-scopes.ts | 2 +- server/adapters/repos/api-keys.ts | 177 ++- server/adapters/repos/org.test.ts | 16 + server/adapters/repos/org.ts | 10 +- server/app.ts | 3 + server/auth.ts | 16 + .../http/agent-api-keys.integration.test.ts | 346 ++++++ server/http/agent-api-keys.ts | 134 +++ server/http/shares.ts | 2 + server/middleware/auth.ts | 1 + server/middleware/authz.ts | 1 + server/usecases/agent-api-keys.ts | 111 ++ server/usecases/object.ts | 2 +- server/usecases/ports/api-keys.ts | 15 + server/usecases/ports/org.ts | 1 + server/usecases/team.test.ts | 1 + shared/api-key-templates.ts | 50 + shared/schemas/agent-api-keys.ts | 70 ++ shared/schemas/index.ts | 22 + spec/agent-api-keys.feature | 48 + src/i18n/locales/en.json | 50 + src/i18n/locales/zh.json | 50 + src/lib/api.test.ts | 94 ++ src/lib/api.ts | 40 + src/lib/rpc.ts | 2 + src/routeTree.gen.ts | 23 + .../settings/agent-access.test.tsx | 316 +++++ .../_authenticated/settings/agent-access.tsx | 419 +++++++ src/routes/_authenticated/settings/route.tsx | 3 +- 32 files changed, 3086 insertions(+), 19 deletions(-) create mode 100644 server/http/agent-api-keys.integration.test.ts create mode 100644 server/http/agent-api-keys.ts create mode 100644 server/usecases/agent-api-keys.ts create mode 100644 shared/schemas/agent-api-keys.ts create mode 100644 spec/agent-api-keys.feature create mode 100644 src/routes/_authenticated/settings/agent-access.test.tsx create mode 100644 src/routes/_authenticated/settings/agent-access.tsx diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index 7d47f2fd..3fe604cc 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -2763,16 +2763,16 @@ func (e GetAdminAnalyticsTrafficParamsTimeZone) Valid() bool { // Defines values for ListAnnouncementsParamsScope. const ( - Active ListAnnouncementsParamsScope = "active" - All ListAnnouncementsParamsScope = "all" + ListAnnouncementsParamsScopeActive ListAnnouncementsParamsScope = "active" + ListAnnouncementsParamsScopeAll ListAnnouncementsParamsScope = "all" ) // Valid indicates whether the value is a known member of the ListAnnouncementsParamsScope enum. func (e ListAnnouncementsParamsScope) Valid() bool { switch e { - case Active: + case ListAnnouncementsParamsScopeActive: return true - case All: + case ListAnnouncementsParamsScopeAll: return true default: return false @@ -3139,6 +3139,273 @@ func (e GrantUserEntitlementJSONBodyResourceType) Valid() bool { } } +// Defines values for ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes. +const ( + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsCreate ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "objects:create" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsDelete ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "objects:delete" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsRead ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "objects:read" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsUpdate ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "objects:update" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesQuotaRead ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "quota:read" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesSharesCreate ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "shares:create" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesSharesDelete ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "shares:delete" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesSharesRead ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "shares:read" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesStorageUsageRead ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes = "storage-usage:read" +) + +// Valid indicates whether the value is a known member of the ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes enum. +func (e ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes) Valid() bool { + switch e { + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsCreate: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsDelete: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsRead: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesObjectsUpdate: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesQuotaRead: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesSharesCreate: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesSharesDelete: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesSharesRead: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopesStorageUsageRead: + return true + default: + return false + } +} + +// Defines values for ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus. +const ( + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusActive ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus = "active" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusExpired ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus = "expired" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusInaccessible ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus = "inaccessible" + ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusRevoked ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus = "revoked" +) + +// Valid indicates whether the value is a known member of the ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus enum. +func (e ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus) Valid() bool { + switch e { + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusActive: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusExpired: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusInaccessible: + return true + case ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatusRevoked: + return true + default: + return false + } +} + +// Defines values for CreateWorkspaceAgentApiKeyJSONBodyScopes. +const ( + CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsCreate CreateWorkspaceAgentApiKeyJSONBodyScopes = "objects:create" + CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsDelete CreateWorkspaceAgentApiKeyJSONBodyScopes = "objects:delete" + CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsRead CreateWorkspaceAgentApiKeyJSONBodyScopes = "objects:read" + CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsUpdate CreateWorkspaceAgentApiKeyJSONBodyScopes = "objects:update" + CreateWorkspaceAgentApiKeyJSONBodyScopesQuotaRead CreateWorkspaceAgentApiKeyJSONBodyScopes = "quota:read" + CreateWorkspaceAgentApiKeyJSONBodyScopesSharesCreate CreateWorkspaceAgentApiKeyJSONBodyScopes = "shares:create" + CreateWorkspaceAgentApiKeyJSONBodyScopesSharesDelete CreateWorkspaceAgentApiKeyJSONBodyScopes = "shares:delete" + CreateWorkspaceAgentApiKeyJSONBodyScopesSharesRead CreateWorkspaceAgentApiKeyJSONBodyScopes = "shares:read" + CreateWorkspaceAgentApiKeyJSONBodyScopesStorageUsageRead CreateWorkspaceAgentApiKeyJSONBodyScopes = "storage-usage:read" +) + +// Valid indicates whether the value is a known member of the CreateWorkspaceAgentApiKeyJSONBodyScopes enum. +func (e CreateWorkspaceAgentApiKeyJSONBodyScopes) Valid() bool { + switch e { + case CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsCreate: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsDelete: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsRead: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesObjectsUpdate: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesQuotaRead: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesSharesCreate: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesSharesDelete: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesSharesRead: + return true + case CreateWorkspaceAgentApiKeyJSONBodyScopesStorageUsageRead: + return true + default: + return false + } +} + +// Defines values for CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes. +const ( + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsCreate CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:create" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsDelete CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:delete" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsRead CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:read" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsUpdate CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:update" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesQuotaRead CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "quota:read" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesCreate CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:create" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesDelete CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:delete" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesRead CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:read" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesStorageUsageRead CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "storage-usage:read" +) + +// Valid indicates whether the value is a known member of the CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes enum. +func (e CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes) Valid() bool { + switch e { + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsCreate: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsDelete: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsRead: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsUpdate: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesQuotaRead: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesCreate: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesDelete: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesRead: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopesStorageUsageRead: + return true + default: + return false + } +} + +// Defines values for CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus. +const ( + CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "active" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "expired" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "inaccessible" + CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "revoked" +) + +// Valid indicates whether the value is a known member of the CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus enum. +func (e CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus) Valid() bool { + switch e { + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible: + return true + case CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked: + return true + default: + return false + } +} + +// Defines values for RotateWorkspaceAgentApiKeyJSONBodyScopes. +const ( + RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsCreate RotateWorkspaceAgentApiKeyJSONBodyScopes = "objects:create" + RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsDelete RotateWorkspaceAgentApiKeyJSONBodyScopes = "objects:delete" + RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsRead RotateWorkspaceAgentApiKeyJSONBodyScopes = "objects:read" + RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsUpdate RotateWorkspaceAgentApiKeyJSONBodyScopes = "objects:update" + RotateWorkspaceAgentApiKeyJSONBodyScopesQuotaRead RotateWorkspaceAgentApiKeyJSONBodyScopes = "quota:read" + RotateWorkspaceAgentApiKeyJSONBodyScopesSharesCreate RotateWorkspaceAgentApiKeyJSONBodyScopes = "shares:create" + RotateWorkspaceAgentApiKeyJSONBodyScopesSharesDelete RotateWorkspaceAgentApiKeyJSONBodyScopes = "shares:delete" + RotateWorkspaceAgentApiKeyJSONBodyScopesSharesRead RotateWorkspaceAgentApiKeyJSONBodyScopes = "shares:read" + RotateWorkspaceAgentApiKeyJSONBodyScopesStorageUsageRead RotateWorkspaceAgentApiKeyJSONBodyScopes = "storage-usage:read" +) + +// Valid indicates whether the value is a known member of the RotateWorkspaceAgentApiKeyJSONBodyScopes enum. +func (e RotateWorkspaceAgentApiKeyJSONBodyScopes) Valid() bool { + switch e { + case RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsCreate: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsDelete: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsRead: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesObjectsUpdate: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesQuotaRead: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesSharesCreate: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesSharesDelete: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesSharesRead: + return true + case RotateWorkspaceAgentApiKeyJSONBodyScopesStorageUsageRead: + return true + default: + return false + } +} + +// Defines values for RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes. +const ( + ObjectsCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:create" + ObjectsDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:delete" + ObjectsRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:read" + ObjectsUpdate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:update" + QuotaRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "quota:read" + SharesCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:create" + SharesDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:delete" + SharesRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:read" + StorageUsageRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "storage-usage:read" +) + +// Valid indicates whether the value is a known member of the RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes enum. +func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes) Valid() bool { + switch e { + case ObjectsCreate: + return true + case ObjectsDelete: + return true + case ObjectsRead: + return true + case ObjectsUpdate: + return true + case QuotaRead: + return true + case SharesCreate: + return true + case SharesDelete: + return true + case SharesRead: + return true + case StorageUsageRead: + return true + default: + return false + } +} + +// Defines values for RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus. +const ( + RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "active" + RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "expired" + RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "inaccessible" + RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "revoked" +) + +// Valid indicates whether the value is a known member of the RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus enum. +func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus) Valid() bool { + switch e { + case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive: + return true + case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired: + return true + case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible: + return true + case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked: + return true + default: + return false + } +} + // ActivityPage defines model for ActivityPage. type ActivityPage struct { Items []AuditEvent `json:"items"` @@ -6893,6 +7160,50 @@ type UpdateUserEntitlementJSONBody struct { Note *string `json:"note,omitempty"` } +// ListWorkspaceAgentApiKeysParams defines parameters for ListWorkspaceAgentApiKeys. +type ListWorkspaceAgentApiKeysParams struct { + Page *int `form:"page,omitempty" json:"page,omitempty"` + PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"` +} + +// ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes defines parameters for ListWorkspaceAgentApiKeys. +type ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes string + +// ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus defines parameters for ListWorkspaceAgentApiKeys. +type ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus string + +// CreateWorkspaceAgentApiKeyJSONBody defines parameters for CreateWorkspaceAgentApiKey. +type CreateWorkspaceAgentApiKeyJSONBody struct { + ExpiresAt time.Time `json:"expiresAt"` + Name string `json:"name"` + Scopes []CreateWorkspaceAgentApiKeyJSONBodyScopes `json:"scopes"` +} + +// CreateWorkspaceAgentApiKeyJSONBodyScopes defines parameters for CreateWorkspaceAgentApiKey. +type CreateWorkspaceAgentApiKeyJSONBodyScopes string + +// CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes defines parameters for CreateWorkspaceAgentApiKey. +type CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes string + +// CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus defines parameters for CreateWorkspaceAgentApiKey. +type CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus string + +// RotateWorkspaceAgentApiKeyJSONBody defines parameters for RotateWorkspaceAgentApiKey. +type RotateWorkspaceAgentApiKeyJSONBody struct { + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Name *string `json:"name,omitempty"` + Scopes *[]RotateWorkspaceAgentApiKeyJSONBodyScopes `json:"scopes,omitempty"` +} + +// RotateWorkspaceAgentApiKeyJSONBodyScopes defines parameters for RotateWorkspaceAgentApiKey. +type RotateWorkspaceAgentApiKeyJSONBodyScopes string + +// RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes defines parameters for RotateWorkspaceAgentApiKey. +type RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes string + +// RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus defines parameters for RotateWorkspaceAgentApiKey. +type RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus string + // BanUserJSONRequestBody defines body for BanUser for application/json ContentType. type BanUserJSONRequestBody BanUserJSONBody @@ -7223,6 +7534,12 @@ type GrantUserEntitlementJSONRequestBody GrantUserEntitlementJSONBody // UpdateUserEntitlementJSONRequestBody defines body for UpdateUserEntitlement for application/json ContentType. type UpdateUserEntitlementJSONRequestBody UpdateUserEntitlementJSONBody +// CreateWorkspaceAgentApiKeyJSONRequestBody defines body for CreateWorkspaceAgentApiKey for application/json ContentType. +type CreateWorkspaceAgentApiKeyJSONRequestBody CreateWorkspaceAgentApiKeyJSONBody + +// RotateWorkspaceAgentApiKeyJSONRequestBody defines body for RotateWorkspaceAgentApiKey for application/json ContentType. +type RotateWorkspaceAgentApiKeyJSONRequestBody RotateWorkspaceAgentApiKeyJSONBody + // AsCloudflareSaasImageDomainSettingsCloudflare0 returns the union data inside the CloudflareSaasImageDomainSettings_Cloudflare as a CloudflareSaasImageDomainSettingsCloudflare0 func (t CloudflareSaasImageDomainSettings_Cloudflare) AsCloudflareSaasImageDomainSettingsCloudflare0() (CloudflareSaasImageDomainSettingsCloudflare0, error) { var body CloudflareSaasImageDomainSettingsCloudflare0 @@ -8810,6 +9127,22 @@ type ClientInterface interface { // GetUserProfile request GetUserProfile(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListWorkspaceAgentApiKeys request + ListWorkspaceAgentApiKeys(ctx context.Context, orgId string, params *ListWorkspaceAgentApiKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateWorkspaceAgentApiKeyWithBody request with any body + CreateWorkspaceAgentApiKeyWithBody(ctx context.Context, orgId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateWorkspaceAgentApiKey(ctx context.Context, orgId string, body CreateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RevokeWorkspaceAgentApiKey request + RevokeWorkspaceAgentApiKey(ctx context.Context, orgId string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RotateWorkspaceAgentApiKeyWithBody request with any body + RotateWorkspaceAgentApiKeyWithBody(ctx context.Context, orgId string, keyId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + RotateWorkspaceAgentApiKey(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) } func (c *Client) GetApiAuthAccountInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -12928,6 +13261,78 @@ func (c *Client) GetUserProfile(ctx context.Context, username string, reqEditors return c.Client.Do(req) } +func (c *Client) ListWorkspaceAgentApiKeys(ctx context.Context, orgId string, params *ListWorkspaceAgentApiKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListWorkspaceAgentApiKeysRequest(c.Server, orgId, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateWorkspaceAgentApiKeyWithBody(ctx context.Context, orgId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateWorkspaceAgentApiKeyRequestWithBody(c.Server, orgId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateWorkspaceAgentApiKey(ctx context.Context, orgId string, body CreateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateWorkspaceAgentApiKeyRequest(c.Server, orgId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RevokeWorkspaceAgentApiKey(ctx context.Context, orgId string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRevokeWorkspaceAgentApiKeyRequest(c.Server, orgId, keyId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RotateWorkspaceAgentApiKeyWithBody(ctx context.Context, orgId string, keyId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRotateWorkspaceAgentApiKeyRequestWithBody(c.Server, orgId, keyId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RotateWorkspaceAgentApiKey(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRotateWorkspaceAgentApiKeyRequest(c.Server, orgId, keyId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // NewGetApiAuthAccountInfoRequest generates requests for GetApiAuthAccountInfo func NewGetApiAuthAccountInfoRequest(server string) (*http.Request, error) { var err error @@ -22971,6 +23376,221 @@ func NewGetUserProfileRequest(server string, username string) (*http.Request, er return req, nil } +// NewListWorkspaceAgentApiKeysRequest generates requests for ListWorkspaceAgentApiKeys +func NewListWorkspaceAgentApiKeysRequest(server string, orgId string, params *ListWorkspaceAgentApiKeysParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orgId", orgId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/workspaces/%s/agent-api-keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Page != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.PageSize != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateWorkspaceAgentApiKeyRequest calls the generic CreateWorkspaceAgentApiKey builder with application/json body +func NewCreateWorkspaceAgentApiKeyRequest(server string, orgId string, body CreateWorkspaceAgentApiKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateWorkspaceAgentApiKeyRequestWithBody(server, orgId, "application/json", bodyReader) +} + +// NewCreateWorkspaceAgentApiKeyRequestWithBody generates requests for CreateWorkspaceAgentApiKey with any type of body +func NewCreateWorkspaceAgentApiKeyRequestWithBody(server string, orgId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orgId", orgId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/workspaces/%s/agent-api-keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewRevokeWorkspaceAgentApiKeyRequest generates requests for RevokeWorkspaceAgentApiKey +func NewRevokeWorkspaceAgentApiKeyRequest(server string, orgId string, keyId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orgId", orgId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "keyId", keyId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/workspaces/%s/agent-api-keys/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRotateWorkspaceAgentApiKeyRequest calls the generic RotateWorkspaceAgentApiKey builder with application/json body +func NewRotateWorkspaceAgentApiKeyRequest(server string, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRotateWorkspaceAgentApiKeyRequestWithBody(server, orgId, keyId, "application/json", bodyReader) +} + +// NewRotateWorkspaceAgentApiKeyRequestWithBody generates requests for RotateWorkspaceAgentApiKey with any type of body +func NewRotateWorkspaceAgentApiKeyRequestWithBody(server string, orgId string, keyId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orgId", orgId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "keyId", keyId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/workspaces/%s/agent-api-keys/%s/rotations", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { for _, r := range c.RequestEditors { if err := r(ctx, req); err != nil { @@ -23932,6 +24552,22 @@ type ClientWithResponsesInterface interface { // GetUserProfileWithResponse request GetUserProfileWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*GetUserProfileResponse, error) + + // ListWorkspaceAgentApiKeysWithResponse request + ListWorkspaceAgentApiKeysWithResponse(ctx context.Context, orgId string, params *ListWorkspaceAgentApiKeysParams, reqEditors ...RequestEditorFn) (*ListWorkspaceAgentApiKeysResponse, error) + + // CreateWorkspaceAgentApiKeyWithBodyWithResponse request with any body + CreateWorkspaceAgentApiKeyWithBodyWithResponse(ctx context.Context, orgId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateWorkspaceAgentApiKeyResponse, error) + + CreateWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, body CreateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateWorkspaceAgentApiKeyResponse, error) + + // RevokeWorkspaceAgentApiKeyWithResponse request + RevokeWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, keyId string, reqEditors ...RequestEditorFn) (*RevokeWorkspaceAgentApiKeyResponse, error) + + // RotateWorkspaceAgentApiKeyWithBodyWithResponse request with any body + RotateWorkspaceAgentApiKeyWithBodyWithResponse(ctx context.Context, orgId string, keyId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RotateWorkspaceAgentApiKeyResponse, error) + + RotateWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*RotateWorkspaceAgentApiKeyResponse, error) } type GetApiAuthAccountInfoResponse struct { @@ -33305,6 +33941,175 @@ func (r GetUserProfileResponse) ContentType() string { return "" } +type ListWorkspaceAgentApiKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + Items []struct { + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + Id string `json:"id"` + LastUsedAt *string `json:"lastUsedAt"` + Name string `json:"name"` + OrgId string `json:"orgId"` + Scopes []ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes `json:"scopes"` + Status ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus `json:"status"` + WorkspaceName *string `json:"workspaceName"` + } `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` + } + JSON403 *Error +} + +// Status returns HTTPResponse.Status +func (r ListWorkspaceAgentApiKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListWorkspaceAgentApiKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListWorkspaceAgentApiKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateWorkspaceAgentApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *struct { + Item struct { + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + Id string `json:"id"` + LastUsedAt *string `json:"lastUsedAt"` + Name string `json:"name"` + OrgId string `json:"orgId"` + Scopes []CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes `json:"scopes"` + Status CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus `json:"status"` + WorkspaceName *string `json:"workspaceName"` + } `json:"item"` + Key string `json:"key"` + } + JSON400 *Error + JSON403 *Error +} + +// Status returns HTTPResponse.Status +func (r CreateWorkspaceAgentApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateWorkspaceAgentApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateWorkspaceAgentApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RevokeWorkspaceAgentApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON403 *Error + JSON404 *Error +} + +// Status returns HTTPResponse.Status +func (r RevokeWorkspaceAgentApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RevokeWorkspaceAgentApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RevokeWorkspaceAgentApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RotateWorkspaceAgentApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *struct { + Item struct { + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + Id string `json:"id"` + LastUsedAt *string `json:"lastUsedAt"` + Name string `json:"name"` + OrgId string `json:"orgId"` + Scopes []RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes `json:"scopes"` + Status RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus `json:"status"` + WorkspaceName *string `json:"workspaceName"` + } `json:"item"` + Key string `json:"key"` + } + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error +} + +// Status returns HTTPResponse.Status +func (r RotateWorkspaceAgentApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RotateWorkspaceAgentApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RotateWorkspaceAgentApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + // GetApiAuthAccountInfoWithResponse request returning *GetApiAuthAccountInfoResponse func (c *ClientWithResponses) GetApiAuthAccountInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiAuthAccountInfoResponse, error) { rsp, err := c.GetApiAuthAccountInfo(ctx, reqEditors...) @@ -36282,6 +37087,58 @@ func (c *ClientWithResponses) GetUserProfileWithResponse(ctx context.Context, us return ParseGetUserProfileResponse(rsp) } +// ListWorkspaceAgentApiKeysWithResponse request returning *ListWorkspaceAgentApiKeysResponse +func (c *ClientWithResponses) ListWorkspaceAgentApiKeysWithResponse(ctx context.Context, orgId string, params *ListWorkspaceAgentApiKeysParams, reqEditors ...RequestEditorFn) (*ListWorkspaceAgentApiKeysResponse, error) { + rsp, err := c.ListWorkspaceAgentApiKeys(ctx, orgId, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListWorkspaceAgentApiKeysResponse(rsp) +} + +// CreateWorkspaceAgentApiKeyWithBodyWithResponse request with arbitrary body returning *CreateWorkspaceAgentApiKeyResponse +func (c *ClientWithResponses) CreateWorkspaceAgentApiKeyWithBodyWithResponse(ctx context.Context, orgId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateWorkspaceAgentApiKeyResponse, error) { + rsp, err := c.CreateWorkspaceAgentApiKeyWithBody(ctx, orgId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateWorkspaceAgentApiKeyResponse(rsp) +} + +func (c *ClientWithResponses) CreateWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, body CreateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateWorkspaceAgentApiKeyResponse, error) { + rsp, err := c.CreateWorkspaceAgentApiKey(ctx, orgId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateWorkspaceAgentApiKeyResponse(rsp) +} + +// RevokeWorkspaceAgentApiKeyWithResponse request returning *RevokeWorkspaceAgentApiKeyResponse +func (c *ClientWithResponses) RevokeWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, keyId string, reqEditors ...RequestEditorFn) (*RevokeWorkspaceAgentApiKeyResponse, error) { + rsp, err := c.RevokeWorkspaceAgentApiKey(ctx, orgId, keyId, reqEditors...) + if err != nil { + return nil, err + } + return ParseRevokeWorkspaceAgentApiKeyResponse(rsp) +} + +// RotateWorkspaceAgentApiKeyWithBodyWithResponse request with arbitrary body returning *RotateWorkspaceAgentApiKeyResponse +func (c *ClientWithResponses) RotateWorkspaceAgentApiKeyWithBodyWithResponse(ctx context.Context, orgId string, keyId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RotateWorkspaceAgentApiKeyResponse, error) { + rsp, err := c.RotateWorkspaceAgentApiKeyWithBody(ctx, orgId, keyId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRotateWorkspaceAgentApiKeyResponse(rsp) +} + +func (c *ClientWithResponses) RotateWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*RotateWorkspaceAgentApiKeyResponse, error) { + rsp, err := c.RotateWorkspaceAgentApiKey(ctx, orgId, keyId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRotateWorkspaceAgentApiKeyResponse(rsp) +} + // ParseGetApiAuthAccountInfoResponse parses an HTTP response from a GetApiAuthAccountInfoWithResponse call func ParseGetApiAuthAccountInfoResponse(rsp *http.Response) (*GetApiAuthAccountInfoResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -49061,3 +49918,204 @@ func ParseGetUserProfileResponse(rsp *http.Response) (*GetUserProfileResponse, e return response, nil } + +// ParseListWorkspaceAgentApiKeysResponse parses an HTTP response from a ListWorkspaceAgentApiKeysWithResponse call +func ParseListWorkspaceAgentApiKeysResponse(rsp *http.Response) (*ListWorkspaceAgentApiKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListWorkspaceAgentApiKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Items []struct { + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + Id string `json:"id"` + LastUsedAt *string `json:"lastUsedAt"` + Name string `json:"name"` + OrgId string `json:"orgId"` + Scopes []ListWorkspaceAgentApiKeys200JSONResponseBodyItemsScopes `json:"scopes"` + Status ListWorkspaceAgentApiKeys200JSONResponseBodyItemsStatus `json:"status"` + WorkspaceName *string `json:"workspaceName"` + } `json:"items"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int `json:"total"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + } + + return response, nil +} + +// ParseCreateWorkspaceAgentApiKeyResponse parses an HTTP response from a CreateWorkspaceAgentApiKeyWithResponse call +func ParseCreateWorkspaceAgentApiKeyResponse(rsp *http.Response) (*CreateWorkspaceAgentApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateWorkspaceAgentApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest struct { + Item struct { + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + Id string `json:"id"` + LastUsedAt *string `json:"lastUsedAt"` + Name string `json:"name"` + OrgId string `json:"orgId"` + Scopes []CreateWorkspaceAgentApiKey201JSONResponseBodyItemScopes `json:"scopes"` + Status CreateWorkspaceAgentApiKey201JSONResponseBodyItemStatus `json:"status"` + WorkspaceName *string `json:"workspaceName"` + } `json:"item"` + Key string `json:"key"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + } + + return response, nil +} + +// ParseRevokeWorkspaceAgentApiKeyResponse parses an HTTP response from a RevokeWorkspaceAgentApiKeyWithResponse call +func ParseRevokeWorkspaceAgentApiKeyResponse(rsp *http.Response) (*RevokeWorkspaceAgentApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RevokeWorkspaceAgentApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + +// ParseRotateWorkspaceAgentApiKeyResponse parses an HTTP response from a RotateWorkspaceAgentApiKeyWithResponse call +func ParseRotateWorkspaceAgentApiKeyResponse(rsp *http.Response) (*RotateWorkspaceAgentApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RotateWorkspaceAgentApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest struct { + Item struct { + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + Id string `json:"id"` + LastUsedAt *string `json:"lastUsedAt"` + Name string `json:"name"` + OrgId string `json:"orgId"` + Scopes []RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes `json:"scopes"` + Status RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus `json:"status"` + WorkspaceName *string `json:"workspaceName"` + } `json:"item"` + Key string `json:"key"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + } + + return response, nil +} diff --git a/docs/design/agent-authentication.md b/docs/design/agent-authentication.md index 9162f588..431c17db 100644 --- a/docs/design/agent-authentication.md +++ b/docs/design/agent-authentication.md @@ -275,11 +275,13 @@ Manual API-key creation is the initial CI path: New Agent keys never use `scope.mode = "user-workspaces"`. One key authorizes one workspace. Expiry is required, defaults to 90 days, and cannot exceed one year. -Use one key per CI environment. +Use one key per CI environment. Personal workspace owners and team +owners/admins can manage Agent keys; team editors cannot issue credentials. The UI lists name, workspace, permission summary, creation, expiry, last use, -and status. Revocation is immediate. Rotation creates a new key and never -reveals or mutates the old secret. +and status. Revocation is immediate. Only active keys can rotate. Rotation +creates a new key and never reveals or mutates the old secret; expired and +revoked keys are terminal, so the user creates a new key instead. ## 11. OpenAPI and Restish v2 Binding @@ -417,7 +419,7 @@ Agent Access settings show two sections: - delegated OAuth grants, with client, workspace, scopes, last use, and revoke; - service API keys, with name, workspace, permissions, expiry, last use, and - revoke/replace. + revoke/rotate for active keys. Revoking a delegated grant invalidates its refresh tokens and prevents new access tokens. Short access-token lifetime bounds any validation-cache delay. diff --git a/docs/roadmap/v2.9.md b/docs/roadmap/v2.9.md index 59493c1d..c9fd4378 100644 --- a/docs/roadmap/v2.9.md +++ b/docs/roadmap/v2.9.md @@ -226,6 +226,7 @@ Defaults: - exactly one workspace - least-privilege permissions selected by the user - explicit name, expiry, last-used time, and revocation +- personal owners and team owners/admins manage keys; editors cannot issue credentials - separate keys for separate Agents and environments - no admin, billing, membership, entitlement, or credential-management access - team membership and role rechecked at authorization boundaries @@ -234,6 +235,9 @@ Defaults: The plaintext key is returned once and stored in a CI secret or another non-interactive secret store. +Only active keys can be rotated. Expired and revoked keys are terminal; create +a new key when a new lifetime or credential is required. + ### Scopes and Presets The server has one canonical authorization vocabulary. OAuth grants, API keys, diff --git a/server/adapters/repos/api-key-scopes.ts b/server/adapters/repos/api-key-scopes.ts index 42ee3ddd..c9330dec 100644 --- a/server/adapters/repos/api-key-scopes.ts +++ b/server/adapters/repos/api-key-scopes.ts @@ -3,7 +3,7 @@ import { inArray } from 'drizzle-orm' import { apikey } from '../../db/auth-schema' import type { Database } from '../../platform/interface' -const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD] +const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD, ApiKeyTemplate.AGENT] export function scopeForApiKey(configId: string, metadata: unknown): ApiKeyScope | null { const scope = parseApiKeyScope(metadata) diff --git a/server/adapters/repos/api-keys.ts b/server/adapters/repos/api-keys.ts index 0076204c..618bc81e 100644 --- a/server/adapters/repos/api-keys.ts +++ b/server/adapters/repos/api-keys.ts @@ -1,12 +1,31 @@ import { defaultKeyHasher } from '@better-auth/api-key' -import { API_KEY_TEMPLATES, type ApiKeyPermissions, type ApiKeyTemplate } from '@shared/api-key-templates' -import { authorizationScope, hasAuthorizationScope } from '@shared/authorization' -import { eq } from 'drizzle-orm' -import { apikey } from '../../db/auth-schema' +import { + AGENT_GRANTABLE_API_KEY_SCOPES, + API_KEY_TEMPLATES, + type ApiKeyPermissions, + ApiKeyTemplate, + type ApiKeyTemplate as ApiKeyTemplateId, + apiKeyMetadata, +} from '@shared/api-key-templates' +import { + type AuthorizationScope, + authorizationScope, + hasAuthorizationScope, + permissionScopes, + scopePermissions, +} from '@shared/authorization' +import type { AgentApiKey, AgentGrantableScope } from '@shared/schemas' +import { and, desc, eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { apikey, organization } from '../../db/auth-schema' +import { executeWriteTransaction } from '../../db/transaction' import type { Database } from '../../platform/interface' import { type ApiKeyAuth, type ApiKeyGateway, ApiKeyRateLimitError, type VerifiedApiKey } from '../../usecases/ports' import { scopeForApiKey } from './api-key-scopes' +const AGENT_API_KEY_PREFIX = 'zpan_agent_' +const AGENT_GRANTABLE_SCOPE_SET = new Set(AGENT_GRANTABLE_API_KEY_SCOPES) + type VerifyApiKeyResult = { valid: boolean error: { message: string; code: string; details?: { tryAgainIn?: number } } | null @@ -53,9 +72,155 @@ export function createApiKeyGateway(): ApiKeyGateway { hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope) { return hasAuthorizationScope(permissions, scope) }, + + async listAgentApiKeys(db, userId, orgId, now) { + const rows = await listAgentRows(db, userId, orgId) + return rows.map((row) => toAgentApiKeyDTO(row, now)) + }, + + async getAgentApiKey(db, userId, orgId, keyId, now) { + const row = await getAgentRow(db, userId, orgId, keyId) + return row ? toAgentApiKeyDTO(row, now) : null + }, + + async issueAgentApiKey(db, input) { + const now = new Date() + const id = crypto.randomUUID() + const key = `${AGENT_API_KEY_PREFIX}${nanoid(48)}` + const hashedKey = await defaultKeyHasher(key) + const insert = db.insert(apikey).values({ + id, + configId: ApiKeyTemplate.AGENT, + name: input.name, + start: key.slice(0, AGENT_API_KEY_PREFIX.length + 6), + referenceId: input.userId, + prefix: AGENT_API_KEY_PREFIX, + key: hashedKey, + enabled: true, + rateLimitEnabled: true, + rateLimitTimeWindow: 60_000, + rateLimitMax: 600, + requestCount: 0, + expiresAt: input.expiresAt, + createdAt: now, + updatedAt: now, + permissions: JSON.stringify(scopePermissions(input.scopes)), + metadata: JSON.stringify(apiKeyMetadata({ mode: 'workspace', orgId: input.orgId })), + }) + const revoke = input.revokeKeyId + ? db.update(apikey).set({ enabled: false, updatedAt: now }).where(eq(apikey.id, input.revokeKeyId)) + : null + await executeWriteTransaction(db, revoke ? [insert, revoke] : [insert]) + const row = await getAgentRow(db, input.userId, input.orgId, id) + if (!row) throw new Error('agent_api_key_create_failed') + return { key, item: toAgentApiKeyDTO(row, now) } + }, + + async revokeAgentApiKey(db, keyId) { + await db.update(apikey).set({ enabled: false, updatedAt: new Date() }).where(eq(apikey.id, keyId)) + }, } } +type AgentApiKeyRow = { + id: string + name: string | null + permissions: string | null + metadata: string | null + enabled: boolean + createdAt: Date | number | string + expiresAt: Date | number | string | null + lastRequest: Date | number | string | null + workspaceName: string | null +} + +async function listAgentRows(db: Database, userId: string, orgId: string): Promise { + const rows = await db + .select({ + id: apikey.id, + name: apikey.name, + permissions: apikey.permissions, + metadata: apikey.metadata, + enabled: apikey.enabled, + createdAt: apikey.createdAt, + expiresAt: apikey.expiresAt, + lastRequest: apikey.lastRequest, + workspaceName: organization.name, + }) + .from(apikey) + .leftJoin(organization, eq(organization.id, orgId)) + .where(and(eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId))) + .orderBy(desc(apikey.createdAt)) + return rows.filter((row) => parseWorkspaceMetadata(row.metadata)?.orgId === orgId) +} + +async function getAgentRow(db: Database, userId: string, orgId: string, keyId: string): Promise { + const rows = await db + .select({ + id: apikey.id, + name: apikey.name, + permissions: apikey.permissions, + metadata: apikey.metadata, + enabled: apikey.enabled, + createdAt: apikey.createdAt, + expiresAt: apikey.expiresAt, + lastRequest: apikey.lastRequest, + workspaceName: organization.name, + }) + .from(apikey) + .leftJoin(organization, eq(organization.id, orgId)) + .where(and(eq(apikey.id, keyId), eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId))) + .limit(1) + const row = rows[0] + return row && parseWorkspaceMetadata(row.metadata)?.orgId === orgId ? row : null +} + +function toAgentApiKeyDTO(row: AgentApiKeyRow, now: Date): AgentApiKey { + const scope = parseWorkspaceMetadata(row.metadata) + if (!scope) throw new Error('agent_api_key_workspace_scope_missing') + const expiresAt = requireDate(row.expiresAt, 'agent_api_key_expiry_missing') + return { + id: row.id, + name: row.name ?? row.id, + orgId: scope.orgId, + workspaceName: row.workspaceName, + scopes: parseStoredScopes(row.permissions), + createdAt: toIso(row.createdAt), + expiresAt: expiresAt.toISOString(), + lastUsedAt: row.lastRequest ? toIso(row.lastRequest) : null, + status: !row.enabled ? 'revoked' : expiresAt <= now ? 'expired' : 'active', + } +} + +function parseWorkspaceMetadata(value: string | null): { orgId: string } | null { + if (!value) return null + const parsed = JSON.parse(value) as { scope?: { mode?: unknown; orgId?: unknown } } + return parsed.scope?.mode === 'workspace' && typeof parsed.scope.orgId === 'string' + ? { orgId: parsed.scope.orgId } + : null +} + +function parseStoredScopes(value: string | null): AgentGrantableScope[] { + if (!value) return [] + const permissions = JSON.parse(value) as ApiKeyPermissions + return permissionScopes(permissions).filter((scope): scope is AgentGrantableScope => + AGENT_GRANTABLE_SCOPE_SET.has(scope), + ) +} + +function requireDate(value: Date | number | string | null, message: string): Date { + if (value === null) throw new Error(message) + const date = new Date(value) + if (Number.isNaN(date.getTime())) throw new Error(message) + return date +} + +function toIso(value: Date | number | string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) throw new Error('invalid_agent_api_key_date') + return date.toISOString() +} + async function normalizeVerifiedApiKey(key: NonNullable): Promise { const scope = scopeForApiKey(key.configId, key.metadata) if (!scope) return null @@ -82,9 +247,9 @@ function throwIfRateLimited(result: VerifyApiKeyResult | null) { throw new ApiKeyRateLimitError(result.error.message, result.error.details?.tryAgainIn) } -async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise { +async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise { const hashedKey = await defaultKeyHasher(rawKey) const rows = await db.select({ configId: apikey.configId }).from(apikey).where(eq(apikey.key, hashedKey)).limit(1) const configId = rows[0]?.configId - return configId && API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate) ? (configId as ApiKeyTemplate) : null + return configId && API_KEY_TEMPLATES.includes(configId as ApiKeyTemplateId) ? (configId as ApiKeyTemplateId) : null } diff --git a/server/adapters/repos/org.test.ts b/server/adapters/repos/org.test.ts index 5bd1eddd..3fcd7df5 100644 --- a/server/adapters/repos/org.test.ts +++ b/server/adapters/repos/org.test.ts @@ -159,3 +159,19 @@ describe('isPersonalOrg', () => { expect(result).toBe(false) }) }) + +describe('canManageAgentAccess', () => { + it.each([ + ['owner', true], + ['admin', true], + ['editor', false], + ['viewer', false], + ])('allows Agent Access management for %s: %s', async (role, expected) => { + const { db } = await createTestApp() + const userId = await insertUser(db) + const orgId = await insertOrg(db, { metadata: '{"type":"team"}' }) + await insertMember(db, orgId, userId, role) + + expect(await createOrgRepo(db).canManageAgentAccess(userId, orgId)).toBe(expected) + }) +}) diff --git a/server/adapters/repos/org.ts b/server/adapters/repos/org.ts index c3564f5c..1f83d462 100644 --- a/server/adapters/repos/org.ts +++ b/server/adapters/repos/org.ts @@ -4,7 +4,7 @@ import { member, organization } from '../../db/auth-schema' import type { Database } from '../../platform/interface' import type { OrgRepo } from '../../usecases/ports' -const ROLE_LEVELS: Record = { owner: 3, editor: 2, viewer: 1, member: 1 } +const ROLE_LEVELS: Record = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 } export function createOrgRepo(db: Database): OrgRepo { // Find the user's personal org, if they still belong to it. New personal orgs @@ -55,5 +55,11 @@ export function createOrgRepo(db: Database): OrgRepo { return orgId === (await findPersonalOrg(userId)) } - return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, isPersonalOrg } + async function canManageAgentAccess(userId: string, orgId: string): Promise { + const role = await getMemberRole(orgId, userId) + if (role !== null) return role === 'owner' || role === 'admin' + return orgId === (await findPersonalOrg(userId)) + } + + return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg } } diff --git a/server/app.ts b/server/app.ts index 197549a9..636cefb8 100644 --- a/server/app.ts +++ b/server/app.ts @@ -9,6 +9,7 @@ import { createDeps } from './composition' import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/webdav-public-url' import { adminOverview } from './http/admin-overview' import { adminStats } from './http/admin-stats' +import agentApiKeys from './http/agent-api-keys' import { serveAvatarBlob } from './http/avatar-blobs' import backgroundJobs from './http/background-jobs' import { configz } from './http/configz' @@ -253,6 +254,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep app.route('/api/objects', objects) app.route('/api/shares', authedShares) app.route('/api/trash', trash) + app.route('/api/workspaces', agentApiKeys) app.route('/api/teams', teams) app.route('/api/teams', adminTeams) app.route('/api/site/storages', storages) @@ -400,3 +402,4 @@ export type AdminAuditRoute = typeof adminAudit export type AdminOverviewRoute = typeof adminOverview export type AdminStatsRoute = typeof adminStats export type StorageUsageRoute = typeof storageUsage +export type AgentApiKeysRoute = typeof agentApiKeys diff --git a/server/auth.ts b/server/auth.ts index ea669555..cb4c515d 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -429,6 +429,9 @@ export async function createAuth( if (!body) return const configId = body.configId if (typeof configId !== 'string' || !API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate)) return + if (configId === ApiKeyTemplate.AGENT) { + throw new APIError('BAD_REQUEST', { message: 'Create Agent API keys from the Agent Access API' }) + } const session = await getSessionFromCtx(ctx) const userId = session?.user.id ?? (typeof body?.userId === 'string' ? body.userId : null) @@ -652,6 +655,19 @@ export async function createAuth( defaultPermissions: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS, }, }, + { + configId: ApiKeyTemplate.AGENT, + references: 'user', + enableMetadata: true, + rateLimit: { + enabled: true, + timeWindow: 60_000, + maxRequests: 600, + }, + permissions: { + defaultPermissions: {}, + }, + }, ]), ], databaseHooks: { diff --git a/server/http/agent-api-keys.integration.test.ts b/server/http/agent-api-keys.integration.test.ts new file mode 100644 index 00000000..91687afd --- /dev/null +++ b/server/http/agent-api-keys.integration.test.ts @@ -0,0 +1,346 @@ +import { defaultKeyHasher } from '@better-auth/api-key' +import { sql } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import { authedHeaders, createTestApp } from '../test/setup.js' + +type TestApp = Awaited> + +function futureIso(days: number): string { + const date = new Date() + date.setDate(date.getDate() + days) + return date.toISOString() +} + +async function getUserAndPersonalOrg(db: TestApp['db'], email = 'test@example.com') { + const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`) + const orgs = await db.all<{ id: string }>(sql` + SELECT o.id + FROM organization o + INNER JOIN member m ON m.organization_id = o.id + WHERE m.user_id = ${users[0]?.id} AND o.metadata LIKE '%"type":"personal"%' + LIMIT 1 + `) + if (!users[0] || !orgs[0]) throw new Error('expected user and personal org') + return { userId: users[0].id, orgId: orgs[0].id } +} + +async function insertStorage(db: TestApp['db']) { + const now = Date.now() + await db.run(sql` + INSERT INTO storages ( + id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, + capacity, used, enabled, status, egress_credit_billing_enabled, egress_credit_unit_bytes, + egress_credit_per_unit, created_at, updated_at + ) + VALUES ( + 'st-agent', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', + 'AKIAIOSFODNN7EXAMPLE', 'secret', '', '', 0, 0, 1, 'untested', + 0, ${100 * 1024 ** 2}, 1, ${now}, ${now} + ) + `) +} + +async function insertFile(db: TestApp['db'], orgId: string, id: string) { + const now = Date.now() + await db.run(sql` + INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at) + VALUES (${id}, ${orgId}, ${`${id}-alias`}, ${`${id}.txt`}, 'text/plain', 100, 0, '', 'some/key.txt', 'st-agent', 'active', NULL, ${now}, ${now}) + `) +} + +async function insertLandingShare( + db: TestApp['db'], + input: { token: string; orgId: string; matterId: string; userId: string }, +) { + const now = Date.now() + await db.run(sql` + INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, status, private, created_at) + VALUES (${`${input.token}-id`}, ${input.token}, 'landing', ${input.matterId}, ${input.orgId}, ${input.userId}, 'active', 0, ${now}) + `) +} + +async function insertTeamOrg(db: TestApp['db'], orgId: string, userId: string, role = 'editor') { + const now = Date.now() + await db.run(sql` + INSERT INTO organization (id, name, slug, metadata, created_at, updated_at) + VALUES (${orgId}, ${`Team ${orgId}`}, ${orgId}, '{"type":"team"}', ${now}, ${now}) + `) + await db.run(sql` + INSERT INTO member (id, organization_id, user_id, role, created_at) + VALUES (${`${orgId}-member`}, ${orgId}, ${userId}, ${role}, ${now}) + `) + await db.run(sql` + INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period) + VALUES (${`${orgId}-quota`}, ${orgId}, 1000000, 0, 0, 0, '1970-01') + `) +} + +async function createAgentKey(app: TestApp['app'], headers: Record, orgId: string, scopes: string[]) { + const res = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'CI', scopes, expiresAt: futureIso(90) }), + }) + if (res.status !== 201) throw new Error(`create failed: ${res.status} ${await res.text()}`) + return (await res.json()) as { key: string; item: { id: string; orgId: string; scopes: string[]; status: string } } +} + +async function insertLegacyAgentKey(db: TestApp['db'], userId: string): Promise { + const now = Date.now() + const key = 'zpan_agent_legacy_integration_key' + const hashedKey = await defaultKeyHasher(key) + await db.run(sql` + INSERT INTO apikey ( + id, config_id, name, start, reference_id, prefix, key, + enabled, rate_limit_enabled, rate_limit_time_window, rate_limit_max, request_count, + expires_at, created_at, updated_at, permissions, metadata + ) + VALUES ( + 'legacy-agent-key', 'agent', 'Legacy Agent key', 'zpan_age', ${userId}, 'zpan_agent_', ${hashedKey}, + 1, 1, 60000, 600, 0, + ${now + 90 * 24 * 60 * 60 * 1000}, ${now}, ${now}, '{"objects":["read"]}', NULL + ) + `) + return key +} + +describe('Agent API keys', () => { + it('creates, lists, rotates, and revokes a personal workspace key [spec: agent-api-keys/lifecycle]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const { orgId } = await getUserAndPersonalOrg(db) + + const created = await createAgentKey(app, headers, orgId, ['objects:read']) + expect(created.key).toMatch(/^zpan_agent_/) + expect(created.item).toMatchObject({ orgId, scopes: ['objects:read'], status: 'active' }) + + const list = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, { headers }) + expect(list.status).toBe(200) + const listed = (await list.json()) as { items: Array<{ id: string; key?: string }> } + expect(listed.items.map((item) => item.id)).toContain(created.item.id) + expect(listed.items[0]?.key).toBeUndefined() + + const rotated = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}/rotations`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(rotated.status).toBe(201) + const rotatedBody = (await rotated.json()) as { key: string; item: { id: string } } + expect(rotatedBody.key).toMatch(/^zpan_agent_/) + expect(rotatedBody.item.id).not.toBe(created.item.id) + + const revoke = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${rotatedBody.item.id}`, { + method: 'DELETE', + headers, + }) + expect(revoke.status).toBe(204) + }) + + it('creates and uses a team workspace key for allowed file operations [spec: agent-api-keys/team-file-ops]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const { userId } = await getUserAndPersonalOrg(db) + await insertTeamOrg(db, 'agent-team', userId, 'owner') + await insertFile(db, 'agent-team', 'agent-readable') + const created = await createAgentKey(app, headers, 'agent-team', ['objects:read', 'objects:create']) + await db.run(sql`UPDATE member SET role = 'editor' WHERE organization_id = 'agent-team' AND user_id = ${userId}`) + const auth = { Authorization: `Bearer ${created.key}` } + + const list = await app.request('/api/objects', { headers: auth }) + expect(list.status).toBe(200) + const listBody = (await list.json()) as { items: Array<{ id: string }> } + expect(listBody.items.map((item) => item.id)).toContain('agent-readable') + + const create = await app.request('/api/objects', { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'agent-folder', type: 'folder', dirtype: 1, parent: '' }), + }) + expect(create.status).toBe(201) + }) + + it('allows team owners and admins to manage keys but denies editors [spec: agent-api-keys/management-role]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const { userId } = await getUserAndPersonalOrg(db) + await insertStorage(db) + await insertTeamOrg(db, 'agent-editor-team', userId, 'editor') + await insertTeamOrg(db, 'agent-admin-team', userId, 'admin') + await insertFile(db, 'agent-admin-team', 'agent-admin-readable') + + const editorList = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', { headers }) + expect(editorList.status).toBe(403) + const editorCreate = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Denied', scopes: ['objects:read'], expiresAt: futureIso(90) }), + }) + expect(editorCreate.status).toBe(403) + + const adminCreated = await createAgentKey(app, headers, 'agent-admin-team', ['objects:read']) + expect(adminCreated.item.orgId).toBe('agent-admin-team') + const adminList = await app.request('/api/objects', { + headers: { Authorization: `Bearer ${adminCreated.key}` }, + }) + expect(adminList.status).toBe(200) + }) + + it('rejects disallowed scopes and raw Better Auth Agent key creation [spec: agent-api-keys/scope-boundary]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const { orgId } = await getUserAndPersonalOrg(db) + + const disallowed = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'bad', scopes: ['images:upload'], expiresAt: futureIso(90) }), + }) + expect(disallowed.status).toBe(400) + + const raw = await app.request('/api/auth/api-key/create', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ configId: 'agent', organizationId: orgId, permissions: { images: ['upload'] } }), + }) + expect(raw.status).toBe(400) + }) + + it('denies missing scope, wrong workspace, revoked key, expired key, and banned owner [spec: agent-api-keys/denials]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const { orgId, userId } = await getUserAndPersonalOrg(db) + await insertStorage(db) + const created = await createAgentKey(app, headers, orgId, ['objects:create']) + const auth = { Authorization: `Bearer ${created.key}` } + + const missingScope = await app.request('/api/objects', { headers: auth }) + expect(missingScope.status).toBe(403) + + const wrongWorkspace = await app.request('/api/objects?orgId=agent-other-workspace', { headers: auth }) + expect(wrongWorkspace.status).toBe(403) + + await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}`, { method: 'DELETE', headers }) + const revoked = await app.request('/api/objects', { headers: auth }) + expect(revoked.status).toBe(401) + + const expired = await createAgentKey(app, headers, orgId, ['objects:read']) + await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`) + const expiredRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${expired.key}` } }) + expect(expiredRes.status).toBe(401) + + const banned = await createAgentKey(app, headers, orgId, ['objects:read']) + await db.run(sql`UPDATE user SET banned = 1 WHERE id = ${userId}`) + const bannedRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${banned.key}` } }) + expect(bannedRes.status).toBe(401) + }) + + it('treats expired and revoked keys as terminal for rotation [spec: agent-api-keys/terminal-rotation]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const { orgId } = await getUserAndPersonalOrg(db) + + const expired = await createAgentKey(app, headers, orgId, ['objects:read']) + await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`) + const expiredRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${expired.item.id}/rotations`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ expiresAt: futureIso(90) }), + }) + expect(expiredRotation.status).toBe(409) + await expect(expiredRotation.json()).resolves.toMatchObject({ + error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] }, + }) + + const revoked = await createAgentKey(app, headers, orgId, ['objects:read']) + await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}`, { + method: 'DELETE', + headers, + }) + const revokedRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}/rotations`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(revokedRotation.status).toBe(409) + await expect(revokedRotation.json()).resolves.toMatchObject({ + error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] }, + }) + }) + + it('rechecks team role before management and file operations [spec: agent-api-keys/role-reduction]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const { userId } = await getUserAndPersonalOrg(db) + await insertStorage(db) + await insertTeamOrg(db, 'agent-role-team', userId, 'owner') + await insertFile(db, 'agent-role-team', 'agent-role-share-file') + await insertLandingShare(db, { + token: 'agent-role-share', + orgId: 'agent-role-team', + matterId: 'agent-role-share-file', + userId, + }) + const created = await createAgentKey(app, headers, 'agent-role-team', [ + 'objects:create', + 'shares:create', + 'shares:delete', + ]) + await db.run( + sql`UPDATE member SET role = 'viewer' WHERE organization_id = 'agent-role-team' AND user_id = ${userId}`, + ) + const auth = { Authorization: `Bearer ${created.key}`, 'Content-Type': 'application/json' } + + const management = await app.request('/api/workspaces/agent-role-team/agent-api-keys', { headers }) + expect(management.status).toBe(403) + + const create = await app.request('/api/objects', { + method: 'POST', + headers: auth, + body: JSON.stringify({ name: 'blocked', type: 'folder', dirtype: 1, parent: '' }), + }) + expect(create.status).toBe(403) + + const privacy = await app.request('/api/shares/agent-role-share/privacy', { + method: 'PUT', + headers: auth, + body: JSON.stringify({ private: true }), + }) + expect(privacy.status).toBe(403) + + const revoke = await app.request('/api/shares/agent-role-share/status', { + method: 'PUT', + headers: auth, + body: JSON.stringify({ status: 'revoked' }), + }) + expect(revoke.status).toBe(403) + }) + + it('denies an old team workspace key after the owner membership is removed [spec: agent-api-keys/denials]', async () => { + const { app, db } = await createTestApp() + const headers = await authedHeaders(app) + const { userId } = await getUserAndPersonalOrg(db) + await insertStorage(db) + await insertTeamOrg(db, 'agent-removed-team', userId, 'owner') + await insertFile(db, 'agent-removed-team', 'agent-removed-readable') + const created = await createAgentKey(app, headers, 'agent-removed-team', ['objects:read']) + + await db.run(sql`DELETE FROM member WHERE organization_id = 'agent-removed-team' AND user_id = ${userId}`) + + const denied = await app.request('/api/objects?orgId=agent-removed-team', { + headers: { Authorization: `Bearer ${created.key}` }, + }) + expect(denied.status).toBe(403) + }) + + it('denies a legacy Better Auth Agent key without scoped metadata [spec: agent-api-keys/denials]', async () => { + const { app, db } = await createTestApp() + await authedHeaders(app) + const { userId } = await getUserAndPersonalOrg(db) + const key = await insertLegacyAgentKey(db, userId) + + const denied = await app.request('/api/objects', { headers: { Authorization: `Bearer ${key}` } }) + expect(denied.status).toBe(401) + }) +}) diff --git a/server/http/agent-api-keys.ts b/server/http/agent-api-keys.ts new file mode 100644 index 00000000..6835654d --- /dev/null +++ b/server/http/agent-api-keys.ts @@ -0,0 +1,134 @@ +import { OpenAPIHono, z } from '@hono/zod-openapi' +import { + agentApiKeyCreatedSchema, + agentApiKeyCreateSchema, + agentApiKeyListSchema, + agentApiKeyRotateSchema, +} from '@shared/schemas' +import { requireAuth } from '../middleware/auth' +import type { Env } from '../middleware/platform' +import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '../usecases/agent-api-keys' +import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi' + +const workspaceParamsSchema = z.object({ orgId: z.string().min(1) }) +const keyParamsSchema = workspaceParamsSchema.extend({ keyId: z.string().min(1) }) +const listQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(50), +}) + +const listRoute = authRoute( + { access: 'session' }, + { + operationId: 'listWorkspaceAgentApiKeys', + summary: 'List Agent API keys for a workspace', + tags: ['Agent Access'], + method: 'get', + path: '/{orgId}/agent-api-keys', + middleware: [requireAuth] as const, + request: { params: workspaceParamsSchema, query: listQuerySchema }, + responses: { + 200: jsonContent(agentApiKeyListSchema, 'Agent API keys'), + 403: errorResponse('Forbidden'), + }, + }, +) + +const createRoute = authRoute( + { access: 'session' }, + { + operationId: 'createWorkspaceAgentApiKey', + summary: 'Create an Agent API key for a workspace', + tags: ['Agent Access'], + method: 'post', + path: '/{orgId}/agent-api-keys', + middleware: [requireAuth] as const, + request: { params: workspaceParamsSchema, ...jsonBody(agentApiKeyCreateSchema) }, + responses: { + 201: jsonContent(agentApiKeyCreatedSchema, 'Created Agent API key'), + 400: errorResponse('Bad request'), + 403: errorResponse('Forbidden'), + }, + }, +) + +const rotateRoute = authRoute( + { access: 'session' }, + { + operationId: 'rotateWorkspaceAgentApiKey', + summary: 'Rotate an Agent API key for a workspace', + tags: ['Agent Access'], + method: 'post', + path: '/{orgId}/agent-api-keys/{keyId}/rotations', + middleware: [requireAuth] as const, + request: { params: keyParamsSchema, ...jsonBody(agentApiKeyRotateSchema) }, + responses: { + 201: jsonContent(agentApiKeyCreatedSchema, 'Rotated Agent API key'), + 400: errorResponse('Bad request'), + 409: errorResponse('Agent API key is not active'), + 403: errorResponse('Forbidden'), + 404: errorResponse('Agent API key not found'), + }, + }, +) + +const revokeRoute = authRoute( + { access: 'session' }, + { + operationId: 'revokeWorkspaceAgentApiKey', + summary: 'Revoke an Agent API key for a workspace', + tags: ['Agent Access'], + method: 'delete', + path: '/{orgId}/agent-api-keys/{keyId}', + middleware: [requireAuth] as const, + request: { params: keyParamsSchema }, + responses: { + 204: { description: 'Revoked' }, + 403: errorResponse('Forbidden'), + 404: errorResponse('Agent API key not found'), + }, + }, +) + +const agentApiKeys = new OpenAPIHono() + .openapi(listRoute, async (c) => { + const { orgId } = c.req.valid('param') + const { page, pageSize } = c.req.valid('query') + const result = await listAgentApiKeys(c.get('deps'), c.get('platform').db, { + userId: c.get('userId')!, + orgId, + page, + pageSize, + }) + return c.json(result, 200) + }) + .openapi(createRoute, async (c) => { + const { orgId } = c.req.valid('param') + const result = await createAgentApiKey(c.get('deps'), c.get('platform').db, { + userId: c.get('userId')!, + orgId, + body: c.req.valid('json'), + }) + return c.json(result, 201) + }) + .openapi(rotateRoute, async (c) => { + const { orgId, keyId } = c.req.valid('param') + const result = await rotateAgentApiKey(c.get('deps'), c.get('platform').db, { + userId: c.get('userId')!, + orgId, + keyId, + body: c.req.valid('json'), + }) + return c.json(result, 201) + }) + .openapi(revokeRoute, async (c) => { + const { orgId, keyId } = c.req.valid('param') + await revokeAgentApiKey(c.get('deps'), c.get('platform').db, { + userId: c.get('userId')!, + orgId, + keyId, + }) + return c.body(null, 204) + }) + +export default agentApiKeys diff --git a/server/http/shares.ts b/server/http/shares.ts index ab68557c..b801b00c 100644 --- a/server/http/shares.ts +++ b/server/http/shares.ts @@ -450,6 +450,7 @@ const revokeShareRoute = authRoute( { access: 'protected', scopes: [AuthorizationScope.SHARES_DELETE], + minTeamRole: 'editor', }, { operationId: 'revokeShare', @@ -475,6 +476,7 @@ const putSharePrivacyRoute = authRoute( { access: 'protected', scopes: [AuthorizationScope.SHARES_CREATE], + minTeamRole: 'editor', }, { operationId: 'putSharePrivacy', diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 393d0a50..3c0f938c 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -13,6 +13,7 @@ import { anonymousAuthzContext, type Env } from './platform' // existing org members get read access rather than being silently denied. const ROLE_LEVELS: Record = { owner: 3, + admin: 3, editor: 2, viewer: 1, member: 1, diff --git a/server/middleware/authz.ts b/server/middleware/authz.ts index 17da7316..66f14b8c 100644 --- a/server/middleware/authz.ts +++ b/server/middleware/authz.ts @@ -7,6 +7,7 @@ import type { AuthzContext, Env } from './platform' const ROLE_LEVELS: Record = { owner: 3, + admin: 3, editor: 2, viewer: 1, member: 1, diff --git a/server/usecases/agent-api-keys.ts b/server/usecases/agent-api-keys.ts new file mode 100644 index 00000000..11d04810 --- /dev/null +++ b/server/usecases/agent-api-keys.ts @@ -0,0 +1,111 @@ +import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates' +import type { + AgentApiKeyCreated, + AgentApiKeyCreateInput, + AgentApiKeyList, + AgentApiKeyRotateInput, + AgentGrantableScope, +} from '@shared/schemas' +import type { Database } from '../platform/interface' +import type { Deps } from './deps' +import { badRequest, conflict, forbidden, notFound } from './ports' + +const MAX_AGENT_API_KEY_AGE_MS = 365 * 24 * 60 * 60 * 1000 +const AGENT_GRANTABLE_SCOPE_SET = new Set(AGENT_GRANTABLE_API_KEY_SCOPES) + +export async function listAgentApiKeys( + deps: Pick, + db: Database, + input: { userId: string; orgId: string; page: number; pageSize: number; now?: Date }, +): Promise { + await requireWorkspaceManager(deps, input.userId, input.orgId) + const items = await deps.apiKeys.listAgentApiKeys(db, input.userId, input.orgId, input.now ?? new Date()) + const offset = (input.page - 1) * input.pageSize + return { + items: items.slice(offset, offset + input.pageSize), + total: items.length, + page: input.page, + pageSize: input.pageSize, + } +} + +export async function createAgentApiKey( + deps: Pick, + db: Database, + input: { userId: string; orgId: string; body: AgentApiKeyCreateInput; now?: Date }, +): Promise { + const now = input.now ?? new Date() + await requireWorkspaceManager(deps, input.userId, input.orgId) + return deps.apiKeys.issueAgentApiKey(db, { + name: input.body.name, + orgId: input.orgId, + userId: input.userId, + scopes: normalizeScopes(input.body.scopes), + expiresAt: parseExpiresAt(input.body.expiresAt, now), + }) +} + +export async function rotateAgentApiKey( + deps: Pick, + db: Database, + input: { userId: string; orgId: string; keyId: string; body: AgentApiKeyRotateInput; now?: Date }, +): Promise { + const now = input.now ?? new Date() + await requireWorkspaceManager(deps, input.userId, input.orgId) + const existing = await deps.apiKeys.getAgentApiKey(db, input.userId, input.orgId, input.keyId, now) + if (!existing) throw notFound('Agent API key not found') + if (existing.status !== 'active') { + throw conflict('Only active Agent API keys can be rotated', 'AGENT_API_KEY_NOT_ACTIVE') + } + return deps.apiKeys.issueAgentApiKey(db, { + name: input.body.name?.trim() || `${existing.name} rotation`, + orgId: input.orgId, + userId: input.userId, + scopes: normalizeScopes(input.body.scopes ?? existing.scopes), + expiresAt: parseExpiresAt(input.body.expiresAt ?? existing.expiresAt, now), + revokeKeyId: existing.id, + }) +} + +export async function revokeAgentApiKey( + deps: Pick, + db: Database, + input: { userId: string; orgId: string; keyId: string; now?: Date }, +): Promise { + await requireWorkspaceManager(deps, input.userId, input.orgId) + const existing = await deps.apiKeys.getAgentApiKey( + db, + input.userId, + input.orgId, + input.keyId, + input.now ?? new Date(), + ) + if (!existing) throw notFound('Agent API key not found') + await deps.apiKeys.revokeAgentApiKey(db, input.keyId) +} + +async function requireWorkspaceManager(deps: Pick, userId: string, orgId: string): Promise { + if (!(await deps.org.canManageAgentAccess(userId, orgId))) { + throw forbidden('Owner or admin access to the workspace is required') + } +} + +function parseExpiresAt(value: string, now: Date): Date { + const expiresAt = new Date(value) + if (Number.isNaN(expiresAt.getTime())) throw badRequest('Invalid expiry') + if (expiresAt <= now) throw badRequest('Agent API key expiry must be in the future') + if (expiresAt.getTime() - now.getTime() > MAX_AGENT_API_KEY_AGE_MS) { + throw badRequest('Agent API key expiry cannot exceed one year') + } + return expiresAt +} + +function normalizeScopes(scopes: readonly string[]): AgentGrantableScope[] { + const unique = new Set(scopes) + if (unique.size !== scopes.length) throw badRequest('Duplicate Agent API key scopes are not allowed') + const normalized = [...unique] as AgentGrantableScope[] + if (normalized.some((scope) => !AGENT_GRANTABLE_SCOPE_SET.has(scope))) { + throw badRequest('Agent API key scope is not grantable') + } + return normalized +} diff --git a/server/usecases/object.ts b/server/usecases/object.ts index 308cb203..e12f51c0 100644 --- a/server/usecases/object.ts +++ b/server/usecases/object.ts @@ -81,7 +81,7 @@ function actorLogId(actor: ObjectActor): string { return actor.kind === 'download-task-upload' ? `downloader:${actor.downloaderId}` : actor.userId } -const ROLE_LEVELS: Record = { owner: 3, editor: 2, viewer: 1, member: 1 } +const ROLE_LEVELS: Record = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 } // Whether the user may write (editor+) in the org. Personal orgs grant full // access to their owner even without a member row. diff --git a/server/usecases/ports/api-keys.ts b/server/usecases/ports/api-keys.ts index 63d7d39c..2dade798 100644 --- a/server/usecases/ports/api-keys.ts +++ b/server/usecases/ports/api-keys.ts @@ -1,5 +1,6 @@ import type { ApiKeyScope } from '@shared/api-key-templates' import type { ApiKeyPermissions, AuthorizationScope } from '@shared/authorization' +import type { AgentApiKey, AgentApiKeyCreated, AgentGrantableScope } from '@shared/schemas' import type { Database } from '../../platform/interface' export interface VerifiedApiKey { @@ -40,4 +41,18 @@ export interface ApiKeyGateway { ): Promise hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource: string, action: string): boolean hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope: AuthorizationScope): boolean + listAgentApiKeys(db: Database, userId: string, orgId: string, now: Date): Promise + getAgentApiKey(db: Database, userId: string, orgId: string, keyId: string, now: Date): Promise + issueAgentApiKey( + db: Database, + input: { + name: string + userId: string + orgId: string + scopes: AgentGrantableScope[] + expiresAt: Date + revokeKeyId?: string + }, + ): Promise + revokeAgentApiKey(db: Database, keyId: string): Promise } diff --git a/server/usecases/ports/org.ts b/server/usecases/ports/org.ts index c6f90015..2f00efb2 100644 --- a/server/usecases/ports/org.ts +++ b/server/usecases/ports/org.ts @@ -3,5 +3,6 @@ export interface OrgRepo { getMemberRole(orgId: string, userId: string): Promise canReadOrg(userId: string, orgId: string): Promise canWriteToOrg(userId: string, orgId: string): Promise + canManageAgentAccess(userId: string, orgId: string): Promise isPersonalOrg(orgId: string): Promise } diff --git a/server/usecases/team.test.ts b/server/usecases/team.test.ts index 2fe8a693..f495c5b9 100644 --- a/server/usecases/team.test.ts +++ b/server/usecases/team.test.ts @@ -89,6 +89,7 @@ function makeDeps( getMemberRole: async () => null, canReadOrg: async () => false, canWriteToOrg: async () => false, + canManageAgentAccess: async () => false, isPersonalOrg: async () => false, ...overrides.org, }, diff --git a/shared/api-key-templates.ts b/shared/api-key-templates.ts index 9c258823..4358d281 100644 --- a/shared/api-key-templates.ts +++ b/shared/api-key-templates.ts @@ -6,6 +6,7 @@ export const ApiKeyTemplate = { IHOST: 'ihost', WEBDAV: 'webdav', REMOTE_DOWNLOAD: 'remote-download', + AGENT: 'agent', } as const export type ApiKeyTemplate = (typeof ApiKeyTemplate)[keyof typeof ApiKeyTemplate] @@ -59,10 +60,59 @@ export const REMOTE_DOWNLOAD_API_KEY_PERMISSIONS = { ]), } satisfies ApiKeyPermissions +export const AGENT_GRANTABLE_API_KEY_SCOPES = [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.OBJECTS_CREATE, + AuthorizationScope.OBJECTS_UPDATE, + AuthorizationScope.OBJECTS_DELETE, + AuthorizationScope.SHARES_READ, + AuthorizationScope.SHARES_CREATE, + AuthorizationScope.SHARES_DELETE, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, +] as const + +export const AGENT_API_KEY_PERMISSIONS = scopePermissions(AGENT_GRANTABLE_API_KEY_SCOPES) + +export const AgentApiKeyShortcut = { + READER: 'reader', + FILE_MANAGER: 'file-manager', + PUBLISHER: 'publisher', +} as const + +export type AgentApiKeyShortcut = (typeof AgentApiKeyShortcut)[keyof typeof AgentApiKeyShortcut] + +export const AGENT_API_KEY_SHORTCUT_SCOPES = { + [AgentApiKeyShortcut.READER]: [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.SHARES_READ, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, + ], + [AgentApiKeyShortcut.FILE_MANAGER]: [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.OBJECTS_CREATE, + AuthorizationScope.OBJECTS_UPDATE, + AuthorizationScope.OBJECTS_DELETE, + AuthorizationScope.SHARES_READ, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, + ], + [AgentApiKeyShortcut.PUBLISHER]: [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.SHARES_READ, + AuthorizationScope.SHARES_CREATE, + AuthorizationScope.SHARES_DELETE, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, + ], +} as const satisfies Record + export const API_KEY_TEMPLATE_PERMISSIONS = { [ApiKeyTemplate.IHOST]: IHOST_API_KEY_PERMISSIONS, [ApiKeyTemplate.WEBDAV]: WEBDAV_API_KEY_PERMISSIONS, [ApiKeyTemplate.REMOTE_DOWNLOAD]: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS, + [ApiKeyTemplate.AGENT]: AGENT_API_KEY_PERMISSIONS, } satisfies Record export const API_KEY_TEMPLATES = Object.values(ApiKeyTemplate) diff --git a/shared/schemas/agent-api-keys.ts b/shared/schemas/agent-api-keys.ts new file mode 100644 index 00000000..da989b0a --- /dev/null +++ b/shared/schemas/agent-api-keys.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' +import { + AGENT_API_KEY_SHORTCUT_SCOPES, + AGENT_GRANTABLE_API_KEY_SCOPES, + AgentApiKeyShortcut, +} from '../api-key-templates' +import { AuthorizationScope } from '../authorization' + +export const agentGrantableScopeSchema = z.enum(AGENT_GRANTABLE_API_KEY_SCOPES) +export type AgentGrantableScope = z.infer + +export const agentApiKeyShortcutSchema = z.enum(Object.values(AgentApiKeyShortcut)) +export type AgentApiKeyShortcutInput = z.infer + +export const agentApiKeyCreateSchema = z.object({ + name: z.string().trim().min(1).max(120), + scopes: z.array(agentGrantableScopeSchema).min(1), + expiresAt: z.string().datetime(), +}) +export type AgentApiKeyCreateInput = z.infer + +export const agentApiKeyRotateSchema = agentApiKeyCreateSchema.partial({ name: true, scopes: true, expiresAt: true }) +export type AgentApiKeyRotateInput = z.infer + +export const agentApiKeyStatusSchema = z.enum(['active', 'expired', 'revoked', 'inaccessible']) +export type AgentApiKeyStatus = z.infer + +export const agentApiKeySchema = z.object({ + id: z.string(), + name: z.string(), + orgId: z.string(), + workspaceName: z.string().nullable(), + scopes: z.array(agentGrantableScopeSchema), + createdAt: z.string(), + expiresAt: z.string(), + lastUsedAt: z.string().nullable(), + status: agentApiKeyStatusSchema, +}) +export type AgentApiKey = z.infer + +export const agentApiKeyListSchema = z.object({ + items: z.array(agentApiKeySchema), + total: z.number().int(), + page: z.number().int(), + pageSize: z.number().int(), +}) +export type AgentApiKeyList = z.infer + +export const agentApiKeyCreatedSchema = z.object({ + key: z.string(), + item: agentApiKeySchema, +}) +export type AgentApiKeyCreated = z.infer + +export const agentApiKeyShortcutOptions = Object.entries(AGENT_API_KEY_SHORTCUT_SCOPES).map(([id, scopes]) => ({ + id: id as AgentApiKeyShortcutInput, + scopes: [...scopes], +})) + +export const agentScopeLabels = { + [AuthorizationScope.OBJECTS_READ]: 'settings.agentAccess.scope.objectsRead', + [AuthorizationScope.OBJECTS_CREATE]: 'settings.agentAccess.scope.objectsCreate', + [AuthorizationScope.OBJECTS_UPDATE]: 'settings.agentAccess.scope.objectsUpdate', + [AuthorizationScope.OBJECTS_DELETE]: 'settings.agentAccess.scope.objectsDelete', + [AuthorizationScope.SHARES_READ]: 'settings.agentAccess.scope.sharesRead', + [AuthorizationScope.SHARES_CREATE]: 'settings.agentAccess.scope.sharesCreate', + [AuthorizationScope.SHARES_DELETE]: 'settings.agentAccess.scope.sharesDelete', + [AuthorizationScope.QUOTA_READ]: 'settings.agentAccess.scope.quotaRead', + [AuthorizationScope.STORAGE_USAGE_READ]: 'settings.agentAccess.scope.storageUsageRead', +} as const satisfies Record diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index a0cc37b4..3259ce28 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -9,6 +9,28 @@ export { adminAnalyticsTrafficSchema, adminOverviewSchema, } from './admin-analytics' +export type { + AgentApiKey, + AgentApiKeyCreated, + AgentApiKeyCreateInput, + AgentApiKeyList, + AgentApiKeyRotateInput, + AgentApiKeyShortcutInput, + AgentApiKeyStatus, + AgentGrantableScope, +} from './agent-api-keys' +export { + agentApiKeyCreatedSchema, + agentApiKeyCreateSchema, + agentApiKeyListSchema, + agentApiKeyRotateSchema, + agentApiKeySchema, + agentApiKeyShortcutOptions, + agentApiKeyShortcutSchema, + agentApiKeyStatusSchema, + agentGrantableScopeSchema, + agentScopeLabels, +} from './agent-api-keys' export type { AnnouncementInput, diff --git a/spec/agent-api-keys.feature b/spec/agent-api-keys.feature new file mode 100644 index 00000000..89631682 --- /dev/null +++ b/spec/agent-api-keys.feature @@ -0,0 +1,48 @@ +Feature: Agent API keys + Workspace-scoped Agent API keys provide a CI and unattended-service credential + path. Keys are owned by one authorizing user, bound to one workspace, grant only + explicit Agent scopes, expire, and are revealed only once. + + @agent-api-keys/lifecycle @api + Scenario: A user manages a personal workspace Agent API key + Given an authenticated personal workspace owner + When they create, list, rotate, and revoke an Agent API key + Then the plaintext key is returned only on create or rotation + And revoked keys stop working immediately + + @agent-api-keys/team-file-ops @api + Scenario: A team Agent API key performs granted file operations + Given a team workspace owner creates an Agent API key with file read and create scopes + When the owner later becomes an editor + Then the key can list files and create folders in that workspace + + @agent-api-keys/management-role @api + Scenario: Team credential management is restricted to owners and admins + Given a team workspace member + When an editor tries to list or create Agent API keys + Then the API denies credential management + And an owner or admin can manage Agent API keys + + @agent-api-keys/scope-boundary @api + Scenario: Agent API keys cannot request non-Agent scopes + Given an authenticated workspace editor + When they request image-hosting or raw Better Auth Agent permissions + Then the API rejects the key creation request + + @agent-api-keys/denials @api + Scenario: Agent API keys fail closed + Given a workspace Agent API key + When the key is missing scope, crosses workspaces, is revoked, expires, or its owner is banned + Then protected APIs reject the request + + @agent-api-keys/role-reduction @api + Scenario: Agent API keys recheck current workspace role + Given a team Agent API key created by an owner + When the owner is reduced to viewer + Then management and editor-only file operations are denied + + @agent-api-keys/terminal-rotation @api + Scenario: Expired and revoked Agent API keys are terminal + Given an expired or revoked Agent API key + When an owner tries to rotate it + Then the API rejects rotation and requires a new key diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 001ff086..0c8fe744 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1127,6 +1127,7 @@ "settings.tabProfile": "Profile", "settings.tabPassword": "Password", "settings.tabApiKeys": "API Keys", + "settings.tabAgentAccess": "Agent Access", "settings.tabWebDav": "WebDAV", "settings.tabImageHosting": "Image Hosting", "settings.profile.section": "Profile", @@ -1200,6 +1201,55 @@ "settings.apiKeys.revokeSuccess": "API key revoked", "settings.apiKeys.orgRequired": "Select a workspace before creating this API key.", "settings.apiKeys.manage": "Manage API Keys", + "settings.agentAccess.section": "Agent Access", + "settings.agentAccess.description": "Manage workspace-scoped Agent API keys for CI and unattended services.", + "settings.agentAccess.workspaceLabel": "Workspace", + "settings.agentAccess.workspacePlaceholder": "Select a workspace", + "settings.agentAccess.create": "Create Key", + "settings.agentAccess.createTitle": "Create Agent API Key", + "settings.agentAccess.createDescription": "Choose a workspace, expiry, and exact scopes.", + "settings.agentAccess.nameLabel": "Name", + "settings.agentAccess.namePlaceholder": "e.g. GitHub Actions deploy", + "settings.agentAccess.expiryLabel": "Expiry", + "settings.agentAccess.shortcutsLabel": "Shortcuts", + "settings.agentAccess.shortcut.reader": "Reader", + "settings.agentAccess.shortcut.file-manager": "File manager", + "settings.agentAccess.shortcut.publisher": "Publisher", + "settings.agentAccess.scope.objectsRead": "Files: read objects", + "settings.agentAccess.scope.objectsCreate": "Files: create objects", + "settings.agentAccess.scope.objectsUpdate": "Files: update objects", + "settings.agentAccess.scope.objectsDelete": "Files: delete objects", + "settings.agentAccess.scope.sharesRead": "Shares: read shares", + "settings.agentAccess.scope.sharesCreate": "Shares: create shares", + "settings.agentAccess.scope.sharesDelete": "Shares: revoke shares", + "settings.agentAccess.scope.quotaRead": "Quota: read workspace quota", + "settings.agentAccess.scope.storageUsageRead": "Storage usage: read workspace usage", + "settings.agentAccess.colName": "Name", + "settings.agentAccess.colWorkspace": "Workspace", + "settings.agentAccess.colScopes": "Scopes", + "settings.agentAccess.colCreated": "Created", + "settings.agentAccess.colExpires": "Expires", + "settings.agentAccess.colLastUsed": "Last Used", + "settings.agentAccess.colStatus": "Status", + "settings.agentAccess.colActions": "Actions", + "settings.agentAccess.status.active": "Active", + "settings.agentAccess.status.expired": "Expired", + "settings.agentAccess.status.revoked": "Revoked", + "settings.agentAccess.status.inaccessible": "Inaccessible", + "settings.agentAccess.noKeys": "No Agent API keys yet", + "settings.agentAccess.managementRequired": "Owner or admin access is required to manage Agent API keys for this workspace.", + "settings.agentAccess.never": "Never", + "settings.agentAccess.copy": "Copy", + "settings.agentAccess.copied": "Copied", + "settings.agentAccess.createSuccess": "Agent API key created", + "settings.agentAccess.rotate": "Rotate", + "settings.agentAccess.rotateSuccess": "Agent API key rotated", + "settings.agentAccess.revoke": "Revoke", + "settings.agentAccess.revokeTitle": "Revoke Agent API Key", + "settings.agentAccess.revokeConfirm": "Revoke Agent API key \"{{name}}\"? Any services using it will stop immediately.", + "settings.agentAccess.revokeSuccess": "Agent API key revoked", + "settings.agentAccess.revealedTitle": "Save Your Agent API Key", + "settings.agentAccess.revealedWarning": "This is the only time this key will be shown. Store it securely.", "settings.appearance.theme.description": "Choose how ZPan looks. Follows your system setting by default.", "settings.appearance.language.description": "The display language for the app.", "settings.appearance.autoSaved": "Changes apply immediately.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b22ad886..9b054d2b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1127,6 +1127,7 @@ "settings.tabProfile": "基本信息", "settings.tabPassword": "密码", "settings.tabApiKeys": "API Key", + "settings.tabAgentAccess": "Agent Access", "settings.tabWebDav": "WebDAV", "settings.tabImageHosting": "图床", "settings.profile.section": "个人资料", @@ -1200,6 +1201,55 @@ "settings.apiKeys.revokeSuccess": "API Key 已撤销", "settings.apiKeys.orgRequired": "创建该 API Key 前请先选择工作区。", "settings.apiKeys.manage": "管理 API Key", + "settings.agentAccess.section": "Agent Access", + "settings.agentAccess.description": "管理用于 CI 和无人值守服务的工作空间级 Agent API Key。", + "settings.agentAccess.workspaceLabel": "工作空间", + "settings.agentAccess.workspacePlaceholder": "选择工作空间", + "settings.agentAccess.create": "创建 Key", + "settings.agentAccess.createTitle": "创建 Agent API Key", + "settings.agentAccess.createDescription": "选择工作空间、过期时间和明确权限。", + "settings.agentAccess.nameLabel": "名称", + "settings.agentAccess.namePlaceholder": "例如:GitHub Actions deploy", + "settings.agentAccess.expiryLabel": "过期时间", + "settings.agentAccess.shortcutsLabel": "快捷模板", + "settings.agentAccess.shortcut.reader": "Reader", + "settings.agentAccess.shortcut.file-manager": "File manager", + "settings.agentAccess.shortcut.publisher": "Publisher", + "settings.agentAccess.scope.objectsRead": "文件:读取对象", + "settings.agentAccess.scope.objectsCreate": "文件:创建对象", + "settings.agentAccess.scope.objectsUpdate": "文件:更新对象", + "settings.agentAccess.scope.objectsDelete": "文件:删除对象", + "settings.agentAccess.scope.sharesRead": "分享:读取分享", + "settings.agentAccess.scope.sharesCreate": "分享:创建分享", + "settings.agentAccess.scope.sharesDelete": "分享:撤销分享", + "settings.agentAccess.scope.quotaRead": "配额:读取工作空间配额", + "settings.agentAccess.scope.storageUsageRead": "存储用量:读取工作空间用量", + "settings.agentAccess.colName": "名称", + "settings.agentAccess.colWorkspace": "工作空间", + "settings.agentAccess.colScopes": "权限", + "settings.agentAccess.colCreated": "创建时间", + "settings.agentAccess.colExpires": "过期时间", + "settings.agentAccess.colLastUsed": "最近使用", + "settings.agentAccess.colStatus": "状态", + "settings.agentAccess.colActions": "操作", + "settings.agentAccess.status.active": "有效", + "settings.agentAccess.status.expired": "已过期", + "settings.agentAccess.status.revoked": "已撤销", + "settings.agentAccess.status.inaccessible": "不可访问", + "settings.agentAccess.noKeys": "暂无 Agent API Key", + "settings.agentAccess.managementRequired": "需要工作空间所有者或管理员权限才能管理 Agent API Key。", + "settings.agentAccess.never": "从未", + "settings.agentAccess.copy": "复制", + "settings.agentAccess.copied": "已复制", + "settings.agentAccess.createSuccess": "Agent API Key 已创建", + "settings.agentAccess.rotate": "轮换", + "settings.agentAccess.rotateSuccess": "Agent API Key 已轮换", + "settings.agentAccess.revoke": "撤销", + "settings.agentAccess.revokeTitle": "撤销 Agent API Key", + "settings.agentAccess.revokeConfirm": "撤销 Agent API Key「{{name}}」?使用该 Key 的服务将立即停止工作。", + "settings.agentAccess.revokeSuccess": "Agent API Key 已撤销", + "settings.agentAccess.revealedTitle": "保存你的 Agent API Key", + "settings.agentAccess.revealedWarning": "该 Key 只会显示一次,请妥善保存。", "settings.appearance.theme.description": "选择 ZPan 的外观,默认跟随系统。", "settings.appearance.language.description": "界面显示语言。", "settings.appearance.autoSaved": "修改即时生效。", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 3fb9085f..b5f6b36d 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -12,6 +12,7 @@ import { connectCloud, continueCloudOrderPayment, copyObject, + createAgentApiKey, createAnnouncement, createBackgroundJob, createCloudBillingPortalSession, @@ -79,6 +80,7 @@ import { listActiveAnnouncements, listAdminAnnouncements, listAdminAuditLogs, + listAgentApiKeys, listAnnouncements, listApiKeys, listAuthProviders, @@ -120,6 +122,7 @@ import { resetBrandingField, restoreObject, retryBackgroundJob, + revokeAgentApiKey, revokeIhostApiKey, revokeOrgEntitlement, revokeRemoteDownloadApiKey, @@ -127,6 +130,7 @@ import { revokeSiteInvitation, revokeUserEntitlement, revokeWebDavAppPassword, + rotateAgentApiKey, runDownloadTaskAction, saveBranding, saveEmailConfig, @@ -3104,6 +3108,96 @@ describe('api', () => { }) }) + describe('Agent Access API keys', () => { + const sampleList = { + items: [ + { + id: 'agent-key-1', + name: 'CI', + orgId: 'org-1', + workspaceName: 'Personal', + scopes: ['objects:read'], + createdAt: '2026-07-29T00:00:00.000Z', + expiresAt: '2026-10-27T00:00:00.000Z', + lastUsedAt: null, + status: 'active', + }, + ], + total: 1, + page: 1, + pageSize: 50, + } + + it('lists workspace Agent API keys through the Hono RPC route', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(sampleList)) + + const result = await listAgentApiKeys('org-1') + + expect(result).toEqual(sampleList) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/workspaces/org-1/agent-api-keys') + expect(url).toContain('page=1') + expect(url).toContain('pageSize=50') + expect(init.method).toBe('GET') + }) + + it('creates a workspace Agent API key with explicit scopes and expiry', async () => { + const payload = { key: 'zpan_agent_secret', item: sampleList.items[0] } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201)) + + const result = await createAgentApiKey('org-1', { + name: 'CI', + scopes: ['objects:read'], + expiresAt: '2026-10-27T00:00:00.000Z', + }) + + expect(result).toEqual(payload) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/workspaces/org-1/agent-api-keys') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ + name: 'CI', + scopes: ['objects:read'], + expiresAt: '2026-10-27T00:00:00.000Z', + }) + }) + + it('rotates a workspace Agent API key without sending the old secret', async () => { + const payload = { key: 'zpan_agent_rotated', item: { ...sampleList.items[0], id: 'agent-key-2' } } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201)) + + const result = await rotateAgentApiKey('org-1', 'agent-key-1', { name: 'CI rotated' }) + + expect(result).toEqual(payload) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1/rotations') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ name: 'CI rotated' }) + }) + + it('revokes a workspace Agent API key with DELETE', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204)) + + await revokeAgentApiKey('org-1', 'agent-key-1') + + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1') + expect(init.method).toBe('DELETE') + }) + + it('throws ApiError when Agent key creation fails', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403)) + + await expect( + createAgentApiKey('org-1', { + name: 'CI', + scopes: ['objects:read'], + expiresAt: '2026-10-27T00:00:00.000Z', + }), + ).rejects.toThrow('Forbidden') + }) + }) + describe('listApiKeys', () => { const sampleKey = { id: 'key-1', diff --git a/src/lib/api.ts b/src/lib/api.ts index c18d0631..0d41ad6e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,11 @@ import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates' import type { OAuthProviderConfig } from '@shared/oauth-providers' import type { + AgentApiKey, + AgentApiKeyCreated, + AgentApiKeyCreateInput, + AgentApiKeyList, + AgentApiKeyRotateInput, AllowedImageMime, AnnouncementInput, CloudCreditBalanceResponse, @@ -97,6 +102,7 @@ import { adminQuotas, adminSiteInvitations, adminTeams, + agentApiKeysApi, announcementsApi, authedSharesApi, authProviders, @@ -1076,6 +1082,40 @@ export function deleteIhostConfig() { }) } +// Agent Access API keys + +export type { AgentApiKey, AgentApiKeyCreated, AgentApiKeyCreateInput, AgentApiKeyList, AgentApiKeyRotateInput } + +export function listAgentApiKeys(orgId: string, page = 1, pageSize = 50) { + return unwrap( + agentApiKeysApi[':orgId']['agent-api-keys'].$get({ + param: { orgId }, + query: { page: String(page), pageSize: String(pageSize) }, + }), + ) +} + +export function createAgentApiKey(orgId: string, input: AgentApiKeyCreateInput) { + return unwrap( + agentApiKeysApi[':orgId']['agent-api-keys'].$post({ param: { orgId }, json: input }), + ) +} + +export function rotateAgentApiKey(orgId: string, keyId: string, input: AgentApiKeyRotateInput = {}) { + return unwrap( + agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].rotations.$post({ + param: { orgId, keyId }, + json: input, + }), + ) +} + +export function revokeAgentApiKey(orgId: string, keyId: string) { + return agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].$delete({ param: { orgId, keyId } }).then((res) => { + if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText })) + }) +} + // Image Host API Keys (via better-auth apiKey plugin) export interface IhostApiKey { diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index 256789db..089a6003 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -6,6 +6,7 @@ import type { AdminSiteInvitationsRoute, AdminStatsRoute, AdminTeamsRoute, + AgentApiKeysRoute, AnnouncementsRoute, AuthedSharesRoute, AuthProvidersRoute, @@ -49,6 +50,7 @@ export const objects = hc('/api/objects', opts) export const downloadTasksApi = hc('/api/downloads/tasks', opts) export const downloaderSelfApi = hc('/api/downloads/downloaders', opts) export const trash = hc('/api/trash', opts) +export const agentApiKeysApi = hc('/api/workspaces', opts) export const storages = hc('/api/site/storages', opts) export const storageUsageApi = hc('/api/storage', opts) export const adminDownloadersApi = hc('/api/downloads/downloaders', opts) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 279753f6..65eb875b 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -39,6 +39,7 @@ import { Route as AuthenticatedSettingsWebdavRouteImport } from './routes/_authe import { Route as AuthenticatedSettingsProfileRouteImport } from './routes/_authenticated/settings/profile' import { Route as AuthenticatedSettingsPasswordRouteImport } from './routes/_authenticated/settings/password' import { Route as AuthenticatedSettingsApiKeysRouteImport } from './routes/_authenticated/settings/api-keys' +import { Route as AuthenticatedSettingsAgentAccessRouteImport } from './routes/_authenticated/settings/agent-access' import { Route as AuthenticatedAdminLicensingRouteImport } from './routes/_authenticated/admin/licensing' import { Route as AuthenticatedAdminDownloadersRouteImport } from './routes/_authenticated/admin/downloaders' import { Route as AuthenticatedAdminDashboardRouteImport } from './routes/_authenticated/admin/dashboard' @@ -222,6 +223,12 @@ const AuthenticatedSettingsApiKeysRoute = path: '/api-keys', getParentRoute: () => AuthenticatedSettingsRouteRoute, } as any) +const AuthenticatedSettingsAgentAccessRoute = + AuthenticatedSettingsAgentAccessRouteImport.update({ + id: '/agent-access', + path: '/agent-access', + getParentRoute: () => AuthenticatedSettingsRouteRoute, + } as any) const AuthenticatedAdminLicensingRoute = AuthenticatedAdminLicensingRouteImport.update({ id: '/licensing', @@ -376,6 +383,7 @@ export interface FileRoutesByFullPath { '/admin/dashboard': typeof AuthenticatedAdminDashboardRoute '/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute '/admin/licensing': typeof AuthenticatedAdminLicensingRoute + '/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute '/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute '/settings/password': typeof AuthenticatedSettingsPasswordRoute '/settings/profile': typeof AuthenticatedSettingsProfileRoute @@ -426,6 +434,7 @@ export interface FileRoutesByTo { '/admin/dashboard': typeof AuthenticatedAdminDashboardRoute '/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute '/admin/licensing': typeof AuthenticatedAdminLicensingRoute + '/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute '/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute '/settings/password': typeof AuthenticatedSettingsPasswordRoute '/settings/profile': typeof AuthenticatedSettingsProfileRoute @@ -481,6 +490,7 @@ export interface FileRoutesById { '/_authenticated/admin/dashboard': typeof AuthenticatedAdminDashboardRoute '/_authenticated/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute '/_authenticated/admin/licensing': typeof AuthenticatedAdminLicensingRoute + '/_authenticated/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute '/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute '/_authenticated/settings/password': typeof AuthenticatedSettingsPasswordRoute '/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileRoute @@ -536,6 +546,7 @@ export interface FileRouteTypes { | '/admin/dashboard' | '/admin/downloaders' | '/admin/licensing' + | '/settings/agent-access' | '/settings/api-keys' | '/settings/password' | '/settings/profile' @@ -586,6 +597,7 @@ export interface FileRouteTypes { | '/admin/dashboard' | '/admin/downloaders' | '/admin/licensing' + | '/settings/agent-access' | '/settings/api-keys' | '/settings/password' | '/settings/profile' @@ -640,6 +652,7 @@ export interface FileRouteTypes { | '/_authenticated/admin/dashboard' | '/_authenticated/admin/downloaders' | '/_authenticated/admin/licensing' + | '/_authenticated/settings/agent-access' | '/_authenticated/settings/api-keys' | '/_authenticated/settings/password' | '/_authenticated/settings/profile' @@ -895,6 +908,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSettingsApiKeysRouteImport parentRoute: typeof AuthenticatedSettingsRouteRoute } + '/_authenticated/settings/agent-access': { + id: '/_authenticated/settings/agent-access' + path: '/agent-access' + fullPath: '/settings/agent-access' + preLoaderRoute: typeof AuthenticatedSettingsAgentAccessRouteImport + parentRoute: typeof AuthenticatedSettingsRouteRoute + } '/_authenticated/admin/licensing': { id: '/_authenticated/admin/licensing' path: '/licensing' @@ -1097,6 +1117,7 @@ const AuthenticatedAdminRouteRouteWithChildren = ) interface AuthenticatedSettingsRouteRouteChildren { + AuthenticatedSettingsAgentAccessRoute: typeof AuthenticatedSettingsAgentAccessRoute AuthenticatedSettingsApiKeysRoute: typeof AuthenticatedSettingsApiKeysRoute AuthenticatedSettingsPasswordRoute: typeof AuthenticatedSettingsPasswordRoute AuthenticatedSettingsProfileRoute: typeof AuthenticatedSettingsProfileRoute @@ -1106,6 +1127,8 @@ interface AuthenticatedSettingsRouteRouteChildren { const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteChildren = { + AuthenticatedSettingsAgentAccessRoute: + AuthenticatedSettingsAgentAccessRoute, AuthenticatedSettingsApiKeysRoute: AuthenticatedSettingsApiKeysRoute, AuthenticatedSettingsPasswordRoute: AuthenticatedSettingsPasswordRoute, AuthenticatedSettingsProfileRoute: AuthenticatedSettingsProfileRoute, diff --git a/src/routes/_authenticated/settings/agent-access.test.tsx b/src/routes/_authenticated/settings/agent-access.test.tsx new file mode 100644 index 00000000..638170fd --- /dev/null +++ b/src/routes/_authenticated/settings/agent-access.test.tsx @@ -0,0 +1,316 @@ +import type { AgentApiKey } from '@shared/schemas' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { toast } from 'sonner' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api' +import { useListOrganizations } from '@/lib/auth-client' +import { AgentAccessSettingsPage } from './agent-access' +import { SettingsLayout } from './route' + +const state = vi.hoisted(() => ({ + orgs: [ + { id: 'org-1', name: 'Personal' }, + { id: 'org-2', name: 'Team Alpha' }, + ], + keys: [] as AgentApiKey[], + webdavEnabled: true, +})) + +const translations: Record = { + 'settings.agentAccess.scope.objectsRead': 'Files: read objects', + 'settings.agentAccess.scope.objectsCreate': 'Files: create objects', + 'settings.agentAccess.scope.objectsUpdate': 'Files: update objects', + 'settings.agentAccess.scope.objectsDelete': 'Files: delete objects', + 'settings.agentAccess.scope.sharesRead': 'Shares: read shares', + 'settings.agentAccess.scope.sharesCreate': 'Shares: create shares', + 'settings.agentAccess.scope.sharesDelete': 'Shares: revoke shares', + 'settings.agentAccess.scope.quotaRead': 'Quota: read workspace quota', + 'settings.agentAccess.scope.storageUsageRead': 'Storage usage: read workspace usage', + 'settings.agentAccess.managementRequired': 'Owner or admin access is required', +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => translations[key] ?? key }), +})) + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@tanstack/react-router', () => ({ + Outlet: () =>
outlet
, + createFileRoute: () => (options: unknown) => options, +})) + +vi.mock('@/components/layout/page-header', () => ({ + PageHeader: () =>
page-header
, +})) + +vi.mock('@/components/layout/page-tabs', () => ({ + PageTabs: ({ items }: { items: Array<{ label: string }> }) =>
{items.map((item) => item.label).join('|')}
, +})) + +vi.mock('@/hooks/use-site-config', () => ({ + useSiteConfig: () => ({ + data: { services: { webdav: { enabled: state.webdavEnabled } } }, + }), +})) + +vi.mock('@/lib/auth-client', () => ({ + useListOrganizations: vi.fn(), +})) + +vi.mock('@/lib/api', () => ({ + createAgentApiKey: vi.fn(), + listAgentApiKeys: vi.fn(), + revokeAgentApiKey: vi.fn(), + rotateAgentApiKey: vi.fn(), +})) + +const queryClients: QueryClient[] = [] + +function renderWithQuery(ui: React.ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + queryClient.setDefaultOptions({ + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false, gcTime: 0 }, + }) + queryClients.push(queryClient) + return render({ui}) +} + +beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) + vi.mocked(useListOrganizations).mockReturnValue({ data: state.orgs } as never) + vi.mocked(listAgentApiKeys).mockImplementation(async (orgId: string) => ({ + items: state.keys.filter((item) => item.orgId === orgId), + total: state.keys.filter((item) => item.orgId === orgId).length, + page: 1, + pageSize: 50, + })) +}) + +afterEach(() => { + cleanup() + for (const queryClient of queryClients.splice(0)) queryClient.clear() + vi.clearAllMocks() + vi.unstubAllGlobals() + state.keys = [] + state.webdavEnabled = true +}) + +describe('Agent Access settings page', () => { + it('loads the first workspace, fetches its keys, and keeps creation inside a dialog', async () => { + renderWithQuery() + + await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1')) + expect(await screen.findByText('settings.agentAccess.noKeys')).toBeTruthy() + expect(screen.queryByLabelText('settings.agentAccess.nameLabel')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' })) + + expect(screen.getByLabelText('settings.agentAccess.nameLabel')).toBeTruthy() + expect(screen.getByLabelText('settings.agentAccess.expiryLabel')).toBeTruthy() + for (const label of [ + 'Files: read objects', + 'Files: create objects', + 'Files: update objects', + 'Files: delete objects', + 'Shares: read shares', + 'Shares: create shares', + 'Shares: revoke shares', + 'Quota: read workspace quota', + 'Storage usage: read workspace usage', + ]) { + expect(screen.getByText(label)).toBeTruthy() + } + expect(screen.queryByText(/settings\.agentAccess\.scope\..*:/)).toBeNull() + }) + + it('creates a workspace Agent API key and reveals the secret once', async () => { + vi.mocked(createAgentApiKey).mockResolvedValue({ + key: 'zpan_agent_secret', + item: { + id: 'agent-key-1', + name: 'CI key', + orgId: 'org-1', + workspaceName: 'Personal', + scopes: ['objects:read'], + createdAt: '2026-07-29T12:00:00.000Z', + expiresAt: '2026-10-27T23:59:59.000Z', + lastUsedAt: null, + status: 'active', + }, + }) + + renderWithQuery() + await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1')) + await screen.findByText('settings.agentAccess.noKeys') + + fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' })) + const dialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.createTitle' }) + fireEvent.change(within(dialog).getByLabelText('settings.agentAccess.nameLabel'), { + target: { value: ' CI key ' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'settings.agentAccess.create' })) + + await waitFor(() => + expect(createAgentApiKey).toHaveBeenCalledWith( + 'org-1', + expect.objectContaining({ + name: 'CI key', + scopes: ['objects:read', 'shares:read', 'quota:read', 'storage-usage:read'], + expiresAt: expect.stringMatching(/T23:59:59\.000Z$/), + }), + ), + ) + expect(screen.getByText('zpan_agent_secret')).toBeTruthy() + expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.createSuccess') + }) + + it('rotates and revokes an existing workspace Agent API key', async () => { + state.keys = [ + { + id: 'agent-key-1', + name: 'CI key', + orgId: 'org-1', + workspaceName: 'Personal', + scopes: ['objects:read'], + createdAt: '2026-07-29T12:00:00.000Z', + expiresAt: '2026-10-27T23:59:59.000Z', + lastUsedAt: null, + status: 'active', + }, + ] + vi.mocked(rotateAgentApiKey).mockResolvedValue({ + key: 'zpan_agent_rotated', + item: { + ...state.keys[0], + id: 'agent-key-2', + }, + }) + vi.mocked(revokeAgentApiKey).mockResolvedValue(undefined) + + renderWithQuery() + await screen.findByText('CI key') + + fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' })) + + await waitFor(() => expect(rotateAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1')) + const revealedDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revealedTitle' }) + expect(within(revealedDialog).getByText('zpan_agent_rotated')).toBeTruthy() + expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.rotateSuccess') + fireEvent.click(within(revealedDialog).getAllByRole('button', { name: 'common.close' })[1]!) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revealedTitle' })).toBeNull()) + + fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' })) + const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' }) + fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' })) + + await waitFor(() => expect(revokeAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1')) + expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.revokeSuccess') + }) + + it('surfaces rotate and revoke errors and lets the revoke dialog close from its close control', async () => { + state.keys = [ + { + id: 'agent-key-1', + name: 'CI key', + orgId: 'org-1', + workspaceName: 'Personal', + scopes: ['objects:read'], + createdAt: '2026-07-29T12:00:00.000Z', + expiresAt: '2026-10-27T23:59:59.000Z', + lastUsedAt: null, + status: 'active', + }, + ] + vi.mocked(rotateAgentApiKey).mockRejectedValue(new Error('rotate failed')) + vi.mocked(revokeAgentApiKey).mockRejectedValue(new Error('revoke failed')) + + renderWithQuery() + await screen.findByText('CI key') + + fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('rotate failed')) + + fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' })) + const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' }) + fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' })) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('revoke failed')) + + fireEvent.click(within(revokeDialog).getByRole('button', { name: 'common.close' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })).toBeNull()) + }) + + it('does not offer rotation for expired or revoked keys', async () => { + state.keys = [ + { + id: 'expired-key', + name: 'Expired key', + orgId: 'org-1', + workspaceName: 'Personal', + scopes: ['objects:read'], + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-02-01T00:00:00.000Z', + lastUsedAt: null, + status: 'expired', + }, + { + id: 'revoked-key', + name: 'Revoked key', + orgId: 'org-1', + workspaceName: 'Personal', + scopes: ['objects:read'], + createdAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-12-01T00:00:00.000Z', + lastUsedAt: null, + status: 'revoked', + }, + ] + + renderWithQuery() + await screen.findByText('Expired key') + expect(screen.getByText('Revoked key')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'settings.agentAccess.rotate' })).toBeNull() + }) + + it('disables credential creation when the workspace management check fails', async () => { + vi.mocked(listAgentApiKeys).mockRejectedValue(new Error('Forbidden')) + + renderWithQuery() + + expect(await screen.findByText('Owner or admin access is required')).toBeTruthy() + expect(screen.getByRole('button', { name: 'settings.agentAccess.create' }).hasAttribute('disabled')).toBe(true) + }) +}) + +describe('Settings layout tabs', () => { + it('includes the Agent Access tab alongside existing settings tabs', () => { + renderWithQuery() + + expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy() + }) + + it('keeps the Agent Access tab when WebDAV is disabled', () => { + state.webdavEnabled = false + + renderWithQuery() + + expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy() + expect(screen.queryByText(/settings\.tabWebDav/)).toBeNull() + }) +}) diff --git a/src/routes/_authenticated/settings/agent-access.tsx b/src/routes/_authenticated/settings/agent-access.tsx new file mode 100644 index 00000000..0d9b0744 --- /dev/null +++ b/src/routes/_authenticated/settings/agent-access.tsx @@ -0,0 +1,419 @@ +import { type AgentGrantableScope, agentApiKeyShortcutOptions, agentScopeLabels } from '@shared/schemas' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { Copy, KeyRound, Plus, RotateCw, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { type AgentApiKey, createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api' +import { useListOrganizations } from '@/lib/auth-client' + +export const Route = createFileRoute('/_authenticated/settings/agent-access')({ + component: AgentAccessSettingsPage, +}) + +interface Organization { + id: string + name: string +} + +interface RevealedKey { + name: string + key: string +} + +const allAgentScopes = Object.keys(agentScopeLabels) as AgentGrantableScope[] + +function defaultExpiryDate(): string { + const date = new Date() + date.setDate(date.getDate() + 90) + return date.toISOString().slice(0, 10) +} + +function expiryDateToIso(value: string): string { + return new Date(`${value}T23:59:59.000Z`).toISOString() +} + +function formatDate(value: string | null) { + return value ? new Date(value).toLocaleString() : null +} + +function CopyButton({ value }: { value: string }) { + const { t } = useTranslation() + return ( + + ) +} + +function CreateAgentKeyDialog({ + open, + orgId, + onOpenChange, + onCreated, +}: { + open: boolean + orgId: string + onOpenChange: (open: boolean) => void + onCreated: (key: RevealedKey) => void +}) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [name, setName] = useState('') + const [expiryDate, setExpiryDate] = useState(defaultExpiryDate) + const [scopes, setScopes] = useState(agentApiKeyShortcutOptions[0]?.scopes ?? []) + + const createMutation = useMutation({ + mutationFn: () => + createAgentApiKey(orgId, { + name: name.trim(), + scopes, + expiresAt: expiryDateToIso(expiryDate), + }), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['agent-api-keys', orgId] }) + onCreated({ name: result.item.name, key: result.key }) + setName('') + setExpiryDate(defaultExpiryDate()) + setScopes(agentApiKeyShortcutOptions[0]?.scopes ?? []) + onOpenChange(false) + toast.success(t('settings.agentAccess.createSuccess')) + }, + onError: (err) => toast.error(err.message), + }) + + function toggleScope(scope: AgentGrantableScope, checked: boolean) { + setScopes((current) => (checked ? [...current, scope] : current.filter((item) => item !== scope))) + } + + return ( + + + + {t('settings.agentAccess.createTitle')} + {t('settings.agentAccess.createDescription')} + +
+
+ + setName(event.target.value)} + placeholder={t('settings.agentAccess.namePlaceholder')} + /> +
+
+ + setExpiryDate(event.target.value)} + /> +
+
+ +
+ {agentApiKeyShortcutOptions.map((shortcut) => ( + + ))} +
+
+
+ {allAgentScopes.map((scope) => { + const checkboxId = `agent-key-scope-${scope}` + return ( +
+ toggleScope(scope, !!checked)} + /> + +
+ ) + })} +
+
+ + + + +
+
+ ) +} + +function oneYearDate(): string { + const date = new Date() + date.setFullYear(date.getFullYear() + 1) + return date.toISOString().slice(0, 10) +} + +function RevealedKeyDialog({ revealedKey, onClose }: { revealedKey: RevealedKey | null; onClose: () => void }) { + const { t } = useTranslation() + if (!revealedKey) return null + return ( + !open && onClose()}> + + + {t('settings.agentAccess.revealedTitle')} + {t('settings.agentAccess.revealedWarning')} + +
+ +
+ {revealedKey.key} + +
+
+ + + +
+
+ ) +} + +function RevokeAgentKeyDialog({ apiKey, onClose }: { apiKey: AgentApiKey | null; onClose: () => void }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const revokeMutation = useMutation({ + mutationFn: async () => { + if (!apiKey) return + await revokeAgentApiKey(apiKey.orgId, apiKey.id) + }, + onSuccess: () => { + if (apiKey) queryClient.invalidateQueries({ queryKey: ['agent-api-keys', apiKey.orgId] }) + toast.success(t('settings.agentAccess.revokeSuccess')) + onClose() + }, + onError: (err) => toast.error(err.message), + }) + if (!apiKey) return null + return ( + !open && onClose()}> + + + {t('settings.agentAccess.revokeTitle')} + {t('settings.agentAccess.revokeConfirm', { name: apiKey.name })} + + + + + + + + ) +} + +export function AgentAccessSettingsPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { data: organizationData } = useListOrganizations() + const organizations = (organizationData ?? []) as Organization[] + const [orgId, setOrgId] = useState('') + const [createOpen, setCreateOpen] = useState(false) + const [revealedKey, setRevealedKey] = useState(null) + const [revoking, setRevoking] = useState(null) + + useEffect(() => { + if (!orgId && organizations[0]) setOrgId(organizations[0].id) + }, [orgId, organizations]) + + const keysQuery = useQuery({ + queryKey: ['agent-api-keys', orgId], + queryFn: () => listAgentApiKeys(orgId), + enabled: !!orgId, + }) + + const rows = keysQuery.data?.items ?? [] + + async function rotate(apiKey: AgentApiKey) { + try { + const result = await rotateAgentApiKey(apiKey.orgId, apiKey.id) + queryClient.invalidateQueries({ queryKey: ['agent-api-keys', apiKey.orgId] }) + setRevealedKey({ name: result.item.name, key: result.key }) + toast.success(t('settings.agentAccess.rotateSuccess')) + } catch (err) { + toast.error(err instanceof Error ? err.message : t('common.error')) + } + } + + return ( +
+ + + {t('settings.agentAccess.section')} + {t('settings.agentAccess.description')} + + + + + +
+ + +
+ {keysQuery.isLoading ? ( +

{t('common.loading')}

+ ) : keysQuery.isError ? ( +

{t('settings.agentAccess.managementRequired')}

+ ) : rows.length === 0 ? ( +

{t('settings.agentAccess.noKeys')}

+ ) : ( + + + + {t('settings.agentAccess.colName')} + {t('settings.agentAccess.colWorkspace')} + {t('settings.agentAccess.colScopes')} + {t('settings.agentAccess.colCreated')} + {t('settings.agentAccess.colExpires')} + {t('settings.agentAccess.colLastUsed')} + {t('settings.agentAccess.colStatus')} + {t('settings.agentAccess.colActions')} + + + + {rows.map((row) => ( + + +
+ + {row.name} +
+
+ {row.workspaceName ?? row.orgId} + +
+ {row.scopes.map((scope) => ( + + {t(agentScopeLabels[scope])} + + ))} +
+
+ {formatDate(row.createdAt)} + {formatDate(row.expiresAt)} + {formatDate(row.lastUsedAt) ?? t('settings.agentAccess.never')} + + + {t(`settings.agentAccess.status.${row.status}`)} + + + + {row.status === 'active' ? ( + + ) : null} + + +
+ ))} +
+
+ )} +
+
+ + setRevealedKey(null)} /> + setRevoking(null)} /> +
+ ) +} diff --git a/src/routes/_authenticated/settings/route.tsx b/src/routes/_authenticated/settings/route.tsx index fccd4359..9620a701 100644 --- a/src/routes/_authenticated/settings/route.tsx +++ b/src/routes/_authenticated/settings/route.tsx @@ -9,7 +9,7 @@ export const Route = createFileRoute('/_authenticated/settings')({ component: SettingsLayout, }) -function SettingsLayout() { +export function SettingsLayout() { const { t } = useTranslation() const { data: siteConfig } = useSiteConfig() @@ -20,6 +20,7 @@ function SettingsLayout() { ? [] : [{ to: '/settings/webdav', label: t('settings.tabWebDav') }]), { to: '/settings/api-keys', label: t('settings.tabApiKeys') }, + { to: '/settings/agent-access', label: t('settings.tabAgentAccess') }, ] return (