diff --git a/frontend/src/__tests__/apikeys.test.ts b/frontend/src/__tests__/apikeys.test.ts index a349278c1..771768ec2 100644 --- a/frontend/src/__tests__/apikeys.test.ts +++ b/frontend/src/__tests__/apikeys.test.ts @@ -21,6 +21,7 @@ jest.mock('../api', () => ({ createApiKey: jest.fn(), revokeApiKey: jest.fn(), deleteApiKey: jest.fn(), + getUserPermissions: jest.fn(), // Real implementation (not mocked): createApiKey encodes the password // field with this before building the request, so tests need the real // encoding to assert on the resulting request shape. @@ -48,9 +49,16 @@ describe('API Keys Module', () => {
+
+ +
- +
@@ -72,6 +80,13 @@ describe('API Keys Module', () => { total_requests_lifetime: 0, top_keys: [], }); + // Default the owner's effective-permissions fetch (issue #61's + // frontend follow-up) so showCreateKeyModal tests that don't care + // about the picker's own behavior aren't forced to stub it too. + (api.getUserPermissions as jest.Mock).mockResolvedValue({ + permissions: [{ action: 'view', resource: 'recommendations' }], + is_admin: false, + }); }); describe('loadApiKeys', () => { @@ -404,6 +419,108 @@ describe('API Keys Module', () => { // Should not throw expect(() => showCreateKeyModal()).not.toThrow(); }); + + // Issue #61 frontend follow-up: the create-key modal now fetches the + // caller's own effective permissions and renders one checkbox per + // entry, since the backend rejects an empty permissions array. + describe('permission picker (issue #61)', () => { + test('fetches and renders the owner\'s effective permissions as unchecked checkboxes', async () => { + (api.getUserPermissions as jest.Mock).mockResolvedValue({ + permissions: [ + { action: 'view', resource: 'recommendations' }, + { action: 'create', resource: 'plans' }, + ], + is_admin: false, + }); + + await showCreateKeyModal(); + + expect(api.getUserPermissions).toHaveBeenCalled(); + const container = document.getElementById('apikey-permissions-list'); + const checkboxes = container?.querySelectorAll('.apikey-permission-checkbox'); + expect(checkboxes?.length).toBe(2); + checkboxes?.forEach(cb => expect(cb.checked).toBe(false)); + expect(container?.textContent).toContain('view:recommendations'); + expect(container?.textContent).toContain('create:plans'); + }); + + test('labels the admin wildcard entry in plain language', async () => { + (api.getUserPermissions as jest.Mock).mockResolvedValue({ + permissions: [{ action: 'admin', resource: '*' }], + is_admin: true, + }); + + await showCreateKeyModal(); + + const container = document.getElementById('apikey-permissions-list'); + expect(container?.textContent).toContain('Admin (all actions, all resources)'); + }); + + test('escapes hostile action/resource values instead of injecting markup', async () => { + const hostile = '">'; + (api.getUserPermissions as jest.Mock).mockResolvedValue({ + permissions: [{ action: hostile, resource: 'plans' }], + is_admin: false, + }); + + await showCreateKeyModal(); + + const container = document.getElementById('apikey-permissions-list'); + // No element was injected -- the quote-breakout attempt stayed + // inside the data-action attribute's value rather than closing it + // and opening a real tag. + expect(container?.querySelector('img')).toBeNull(); + // The checkbox's dataset round-trips the exact hostile string, + // proving escapeHtml neutralized it as data rather than mangling + // or truncating it. + const checkbox = container?.querySelector('.apikey-permission-checkbox'); + expect(checkbox?.dataset['action']).toBe(hostile); + // The visible label text is HTML-escaped, not raw markup. + expect(container?.textContent).toContain(`${hostile}:plans`); + }); + + test('shows a message and no checkboxes when the owner holds no permissions', async () => { + (api.getUserPermissions as jest.Mock).mockResolvedValue({ permissions: [], is_admin: false }); + + await showCreateKeyModal(); + + const container = document.getElementById('apikey-permissions-list'); + expect(container?.querySelectorAll('.apikey-permission-checkbox').length).toBe(0); + expect(container?.textContent).toContain('No permissions available'); + }); + + test('shows an inline error when the permissions fetch fails', async () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + (api.getUserPermissions as jest.Mock).mockRejectedValue(new Error('boom')); + + await showCreateKeyModal(); + + const container = document.getElementById('apikey-permissions-list'); + expect(container?.querySelector('.error')?.textContent).toContain('Failed to load your permissions'); + consoleError.mockRestore(); + }); + + test('Create button starts disabled and enables only once a permission is checked', async () => { + (api.getUserPermissions as jest.Mock).mockResolvedValue({ + permissions: [{ action: 'view', resource: 'recommendations' }], + is_admin: false, + }); + + await showCreateKeyModal(); + + const submitBtn = document.getElementById('create-apikey-submit-btn') as HTMLButtonElement; + expect(submitBtn.disabled).toBe(true); + + const checkbox = document.querySelector('.apikey-permission-checkbox') as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + expect(submitBtn.disabled).toBe(false); + + checkbox.checked = false; + checkbox.dispatchEvent(new Event('change')); + expect(submitBtn.disabled).toBe(true); + }); + }); }); describe('closeCreateKeyModal', () => { @@ -426,8 +543,12 @@ describe('API Keys Module', () => { describe('createApiKey', () => { const expiresAt = new Date('2025-12-31T00:00:00Z'); + // permissions is required (issue #61): the backend rejects an empty + // array with 400, so every real caller (handleCreateApiKey) always has + // a non-empty selection by the time it calls this wrapper. + const permissions = [{ action: 'view', resource: 'recommendations' }]; - test('creates API key with name, password and expiration', async () => { + test('creates API key with name, password, expiration and permissions', async () => { const mockResponse = { api_key: 'full-api-key-value', key_id: 'key-1', @@ -436,17 +557,18 @@ describe('API Keys Module', () => { (api.createApiKey as jest.Mock).mockResolvedValue(mockResponse); - const result = await createApiKey('Test Key', 'my-password', expiresAt); + const result = await createApiKey('Test Key', 'my-password', expiresAt, permissions); expect(api.createApiKey).toHaveBeenCalledWith({ name: 'Test Key', password: Buffer.from('my-password', 'binary').toString('base64'), - expires_at: expiresAt.toISOString() + expires_at: expiresAt.toISOString(), + permissions, }); expect(result.api_key).toBe('full-api-key-value'); }); - test('creates API key with permissions', async () => { + test('sends every permission passed in', async () => { const mockResponse = { api_key: 'full-api-key-value', key_id: 'key-1', @@ -455,14 +577,17 @@ describe('API Keys Module', () => { (api.createApiKey as jest.Mock).mockResolvedValue(mockResponse); - const permissions = [{ action: 'read', resource: '*' }]; - await createApiKey('Test Key', 'my-password', expiresAt, permissions); + const multiplePermissions = [ + { action: 'view', resource: 'recommendations' }, + { action: 'create', resource: 'plans' }, + ]; + await createApiKey('Test Key', 'my-password', expiresAt, multiplePermissions); expect(api.createApiKey).toHaveBeenCalledWith({ name: 'Test Key', password: Buffer.from('my-password', 'binary').toString('base64'), expires_at: expiresAt.toISOString(), - permissions: permissions + permissions: multiplePermissions }); }); @@ -470,7 +595,7 @@ describe('API Keys Module', () => { const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); (api.createApiKey as jest.Mock).mockRejectedValue(new Error('Create failed')); - await expect(createApiKey('Test Key', 'my-password', expiresAt)).rejects.toThrow('Create failed'); + await expect(createApiKey('Test Key', 'my-password', expiresAt, permissions)).rejects.toThrow('Create failed'); expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); @@ -592,6 +717,81 @@ describe('API Keys Module', () => { const errorEl = document.getElementById('create-apikey-error'); expect(errorEl?.textContent).toContain('Failed to create API key'); }); + + // Issue #61: the backend now 400s on an empty/missing permissions + // array, so the request body sent from the form must always carry the + // checked selection. + test('sends the checked permissions in the request body', async () => { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + (api.createApiKey as jest.Mock).mockResolvedValue({ + api_key: 'test-key', + key_id: 'key-1', + key: { id: 'key-1', name: 'Test', key_prefix: 'abc' } + }); + (api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: [] }); + + (document.getElementById('apikey-name') as HTMLInputElement).value = 'Test Key'; + (document.getElementById('apikey-password') as HTMLInputElement).value = 'my-password'; + (document.getElementById('apikey-expires-at') as HTMLInputElement).value = futureDate.toISOString().split('T')[0] || ""; + // The default fixture ships one pre-checked view:recommendations box. + + const event = { preventDefault: jest.fn() } as unknown as Event; + await handleCreateApiKey(event); + + expect(api.createApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Test Key', + permissions: [{ action: 'view', resource: 'recommendations' }], + }) + ); + const [request] = (api.createApiKey as jest.Mock).mock.calls[0] as [{ permissions: unknown[] }]; + expect(request.permissions.length).toBeGreaterThan(0); + }); + + test('rejects submission with an error, and never calls the API, when no permission is checked', async () => { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + (document.getElementById('apikey-name') as HTMLInputElement).value = 'Test Key'; + (document.getElementById('apikey-password') as HTMLInputElement).value = 'my-password'; + (document.getElementById('apikey-expires-at') as HTMLInputElement).value = futureDate.toISOString().split('T')[0] || ""; + const checkbox = document.querySelector('.apikey-permission-checkbox') as HTMLInputElement; + checkbox.checked = false; + + const event = { preventDefault: jest.fn() } as unknown as Event; + await handleCreateApiKey(event); + + const errorEl = document.getElementById('create-apikey-error'); + expect(errorEl?.textContent).toBe('Select at least one permission'); + expect(api.createApiKey).not.toHaveBeenCalled(); + }); + + test('sends every checked permission when more than one is selected', async () => { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + document.getElementById('apikey-permissions-list')!.innerHTML = ` + + + `; + (api.createApiKey as jest.Mock).mockResolvedValue({ + api_key: 'test-key', + key_id: 'key-1', + key: { id: 'key-1', name: 'Test', key_prefix: 'abc' } + }); + (api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: [] }); + (document.getElementById('apikey-name') as HTMLInputElement).value = 'Test Key'; + (document.getElementById('apikey-password') as HTMLInputElement).value = 'my-password'; + (document.getElementById('apikey-expires-at') as HTMLInputElement).value = futureDate.toISOString().split('T')[0] || ""; + + const event = { preventDefault: jest.fn() } as unknown as Event; + await handleCreateApiKey(event); + + expect(api.createApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + permissions: [ + { action: 'view', resource: 'recommendations' }, + { action: 'create', resource: 'plans' }, + ], + }) + ); + }); }); describe('showKeyCreatedModal', () => { diff --git a/frontend/src/apikeys.ts b/frontend/src/apikeys.ts index 9b6b7683d..d8891c7ff 100644 --- a/frontend/src/apikeys.ts +++ b/frontend/src/apikeys.ts @@ -167,17 +167,100 @@ export function renderApiKeysList(): void { } /** - * Show create API key modal + * Format an (action, resource) permission entry for display in the + * create-key picker. No catalog of human labels is maintained here -- + * the values are the closed vocabulary the backend already validates + * (internal/auth/types.go), so the raw "action:resource" pair is + * unambiguous and never goes stale against a separate label list. */ -export function showCreateKeyModal(): void { +function formatPermissionLabel(entry: api.PermissionEntry): string { + if (entry.action === 'admin' && entry.resource === '*') { + return 'Admin (all actions, all resources)'; + } + return `${entry.action}:${entry.resource}`; +} + +/** + * Render the create-key permission picker as one checkbox per entry the + * calling user actually holds (issue #61's frontend follow-up): after the + * backend fix, CreateAPIKey rejects an empty permissions array, so a + * picker limited to the owner's own effective permissions is both the + * fix for the resulting "every key creation 400s" regression and the + * least-privilege UI the per-key scoping feature always intended. + * + * Every interpolated value is escaped even though action/resource come + * from the closed backend vocabulary -- defense in depth for an innerHTML + * write. + */ +function renderApiKeyPermissionPicker(entries: api.PermissionEntry[]): void { + const container = document.getElementById('apikey-permissions-list'); + if (!container) return; + + if (entries.length === 0) { + container.innerHTML = '

No permissions available on your account -- an API key cannot be created.

'; + return; + } + + container.innerHTML = entries.map((entry, i) => ` + + `).join(''); + + container.querySelectorAll('.apikey-permission-checkbox').forEach(cb => { + cb.addEventListener('change', updateCreateKeySubmitState); + }); +} + +/** + * Read the permissions currently checked in the picker. + */ +function collectSelectedApiKeyPermissions(): api.Permission[] { + const container = document.getElementById('apikey-permissions-list'); + if (!container) return []; + + const permissions: api.Permission[] = []; + container.querySelectorAll('.apikey-permission-checkbox:checked').forEach(cb => { + const action = cb.dataset['action']; + const resource = cb.dataset['resource']; + if (action && resource) permissions.push({ action, resource }); + }); + return permissions; +} + +/** + * Enable the Create button only while at least one permission is + * selected. An unscoped key is now rejected server-side (400), and + * disabling here catches it before the round-trip rather than after. + */ +function updateCreateKeySubmitState(): void { + const submitBtn = document.getElementById('create-apikey-submit-btn') as HTMLButtonElement | null; + if (!submitBtn) return; + submitBtn.disabled = collectSelectedApiKeyPermissions().length === 0; +} + +/** + * Show create API key modal. + * + * Fetches the caller's own effective permissions fresh on every open + * (rather than reusing a cached snapshot from login) so the picker + * reflects the account's CURRENT grants -- a group change since login + * shows up immediately instead of offering a permission the key would + * then fail to obtain from the intersection check server-side. + */ +export async function showCreateKeyModal(): Promise { const modal = document.getElementById('create-apikey-modal'); const form = document.getElementById('create-apikey-form') as HTMLFormElement; const errorEl = document.getElementById('create-apikey-error'); + const submitBtn = document.getElementById('create-apikey-submit-btn') as HTMLButtonElement | null; if (!modal || !form) return; form.reset(); if (errorEl) errorEl.classList.add('hidden'); + if (submitBtn) submitBtn.disabled = true; // Expiration is required (issue #102: no more "never expires"). Prefill // 90 days out so the field never starts empty; the actual cap is @@ -189,7 +272,23 @@ export function showCreateKeyModal(): void { expiresAtInput.value = toLocalDateInputValue(defaultDate); } + const permissionsContainer = document.getElementById('apikey-permissions-list'); + if (permissionsContainer) permissionsContainer.innerHTML = '

Loading your permissions…

'; + + // Open the modal before the permissions fetch resolves so the dialog + // never sits closed while waiting on the network. openModal(modal); + + try { + const { permissions } = await api.getUserPermissions(); + renderApiKeyPermissionPicker(permissions); + } catch (error) { + console.error('Failed to load permissions for API key creation:', error); + if (permissionsContainer) { + permissionsContainer.innerHTML = '

Failed to load your permissions. Close and reopen this dialog to retry.

'; + } + } + updateCreateKeySubmitState(); } /** @@ -224,22 +323,20 @@ export function closeCreateKeyModal(): void { } /** - * Create new API key. password and expiresAt are required server-side - * (issue #102): creation re-verifies the caller's password, and a key can - * no longer be minted with no expiration. + * Create new API key. password, expiresAt and permissions are all required + * server-side: creation re-verifies the caller's password (issue #102), a + * key can no longer be minted with no expiration (issue #102), and an + * empty permissions array is rejected outright (issue #61). */ -export async function createApiKey(name: string, password: string, expiresAt: Date, permissions?: api.Permission[]): Promise { +export async function createApiKey(name: string, password: string, expiresAt: Date, permissions: api.Permission[]): Promise { try { const request: api.CreateAPIKeyRequest = { name, password: api.base64Encode(password), expires_at: expiresAt.toISOString(), + permissions, }; - if (permissions && permissions.length > 0) { - request.permissions = permissions; - } - const response = await api.createApiKey(request); return response; } catch (error) { @@ -274,6 +371,16 @@ export async function handleCreateApiKey(e: Event): Promise { return; } + // Belt-and-suspenders alongside the disabled submit button: the backend + // now rejects an empty permissions array with 400 (issue #61), so an + // unscoped key can never be minted. Catching it here avoids the round + // trip and gives a clearer message than the raw 400 body. + const permissions = collectSelectedApiKeyPermissions(); + if (permissions.length === 0) { + showError('Select at least one permission'); + return; + } + const expiresAt = parseLocalDateInputValue(expiresAtInput); if (expiresAt <= new Date()) { showError('Expiration date must be in the future'); @@ -281,7 +388,7 @@ export async function handleCreateApiKey(e: Event): Promise { } try { - const response = await createApiKey(name, password, expiresAt); + const response = await createApiKey(name, password, expiresAt, permissions); closeCreateKeyModal(); showKeyCreatedModal(response.api_key); await loadApiKeys(); @@ -413,7 +520,7 @@ export function initApiKeys(): void { // Setup create key button const createKeyBtn = document.getElementById('create-apikey-btn'); if (createKeyBtn) { - createKeyBtn.addEventListener('click', () => showCreateKeyModal()); + createKeyBtn.addEventListener('click', () => void showCreateKeyModal()); } // Setup close modal button diff --git a/frontend/src/index.html b/frontend/src/index.html index 899f57f89..bef9561e5 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1025,6 +1025,11 @@

Create API Key

Confirm your password to create this API key +
+ +
+ Only the permissions your own account holds are offered. At least one is required -- an unscoped key would authenticate you but authorize nothing. +
@@ -1033,7 +1038,7 @@

Create API Key

diff --git a/internal/api/handler_apikeys.go b/internal/api/handler_apikeys.go index cef22d56e..8486a7125 100644 --- a/internal/api/handler_apikeys.go +++ b/internal/api/handler_apikeys.go @@ -85,6 +85,9 @@ func (h *Handler) createAPIKey(ctx context.Context, req *events.LambdaFunctionUR // - ErrAPIKeyInvalidPassword -> 401: the acting user is re-verifying their // own credential, so a precise message is safe (same treatment as // ErrCurrentPasswordIncorrect). +// - ErrEmptyAPIKeyPermissions -> 400: malformed/insufficient input, not a +// server failure -- surface the specific reason instead of a generic +// 500 (issue #61). // - ErrAPIKeyExpiresAtRequired / ErrAPIKeyExpiresAtTooFar / ErrAPIKeyExpiresAtInPast // -> 400: caller-correctable request validation. // - All other errors pass through unchanged for handleRequestError to @@ -93,7 +96,8 @@ func mapCreateAPIKeyError(err error) error { switch { case errors.Is(err, auth.ErrAPIKeyInvalidPassword): return NewClientError(401, err.Error()) - case errors.Is(err, auth.ErrAPIKeyExpiresAtRequired), + case errors.Is(err, auth.ErrEmptyAPIKeyPermissions), + errors.Is(err, auth.ErrAPIKeyExpiresAtRequired), errors.Is(err, auth.ErrAPIKeyExpiresAtTooFar), errors.Is(err, auth.ErrAPIKeyExpiresAtInPast): return NewClientError(400, err.Error()) diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 76581421a..f8cdf8545 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -2290,7 +2290,7 @@ components: # -- API Keys ----------------------------------------------------------- CreateAPIKeyRequest: type: object - required: [name, password, expires_at] + required: [name, password, permissions, expires_at] properties: name: type: string @@ -2301,6 +2301,12 @@ components: type: array items: $ref: '#/components/schemas/Permission' + minItems: 1 + description: | + At least one permission is required. An unscoped key would + authenticate its owner but authorize nothing (issue #61), so the + API rejects an empty or missing list with 400 rather than + accepting a key that can never be used. expires_at: type: string format: date-time diff --git a/internal/auth/errors.go b/internal/auth/errors.go index 5d3fc72c7..ff75609f7 100644 --- a/internal/auth/errors.go +++ b/internal/auth/errors.go @@ -69,6 +69,13 @@ var ( // credential, so a precise message is safe -- issue #929). ErrCurrentPasswordIncorrect = errors.New("current password is incorrect") + // ErrEmptyAPIKeyPermissions is returned by CreateAPIKey when the request + // carries zero permissions. An unscoped key used to silently inherit the + // owner's full permission set at request time; requiring at least one + // explicit permission at creation forces scoping to be deliberate rather + // than the most-permissive default. Mapped to 400 (issue #61). + ErrEmptyAPIKeyPermissions = errors.New("at least one permission is required") + // MFA login-gate sentinels — used by the login API handler to map // to machine-readable response codes (mfa_required / // invalid_mfa_code) so the frontend can branch on the error class diff --git a/internal/auth/service_apikeys.go b/internal/auth/service_apikeys.go index 06cc7022c..90bd00523 100644 --- a/internal/auth/service_apikeys.go +++ b/internal/auth/service_apikeys.go @@ -45,29 +45,40 @@ const ( MaxAPIKeyLifetime = 365 * 24 * time.Hour ) -// CreateAPIKey creates a new user API key with scoped permissions. The -// caller's current password must be supplied and is re-verified against the -// stored hash before minting a key: a stolen session token alone must not be -// enough to create a durable, potentially wildcard-scoped credential, the -// same defense-in-depth MFASetup/MFADisable already apply (issue #102). -// Returns the full API key (shown only once), key info, and error. -func (s *Service) CreateAPIKey(ctx context.Context, userID, name, password string, permissions []Permission, expiresAt *time.Time) (string, *UserAPIKey, error) { - // Validate user exists and is active +// loadActiveUserForAPIKey resolves userID and ensures the account exists and +// is active. Pulled out of CreateAPIKey to keep its cyclomatic complexity +// under the project's gocyclo gate as new checks (e.g. issue #61's empty- +// permissions rejection, issue #102's password/expiry validation) are added. +func (s *Service) loadActiveUserForAPIKey(ctx context.Context, userID string) (*User, error) { user, err := s.store.GetUserByID(ctx, userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { - return "", nil, fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found") } - return "", nil, fmt.Errorf("failed to get user: %w", err) + return nil, fmt.Errorf("failed to get user: %w", err) } if user == nil { - return "", nil, fmt.Errorf("user not found") + return nil, fmt.Errorf("user not found") } if !user.Active { - return "", nil, fmt.Errorf("user account is not active") + return nil, fmt.Errorf("user account is not active") } + return user, nil +} - if err := s.validateCreateAPIKeyRequest(user, password, name, expiresAt); err != nil { +// CreateAPIKey creates a new user API key with scoped permissions. The +// caller's current password must be supplied and is re-verified against the +// stored hash before minting a key: a stolen session token alone must not be +// enough to create a durable, potentially wildcard-scoped credential, the +// same defense-in-depth MFASetup/MFADisable already apply (issue #102). +// Returns the full API key (shown only once), key info, and error. +func (s *Service) CreateAPIKey(ctx context.Context, userID, name, password string, permissions []Permission, expiresAt *time.Time) (string, *UserAPIKey, error) { + user, err := s.loadActiveUserForAPIKey(ctx, userID) + if err != nil { + return "", nil, err + } + + if err := s.validateCreateAPIKeyRequest(user, password, name, permissions, expiresAt); err != nil { return "", nil, err } @@ -125,16 +136,25 @@ func (s *Service) CreateAPIKey(ctx context.Context, userID, name, password strin } // validateCreateAPIKeyRequest checks the caller-supplied fields of a -// CreateAPIKey call: password re-verification, key name, and the mandatory, -// capped expiry (issue #102). Extracted out of CreateAPIKey to keep its -// cyclomatic complexity under the repo's gocyclo gate. -func (s *Service) validateCreateAPIKeyRequest(user *User, password, name string, expiresAt *time.Time) error { +// CreateAPIKey call: password re-verification, key name, the required +// permissions scope, and the mandatory, capped expiry (issues #61, #102). +// Extracted out of CreateAPIKey to keep its cyclomatic complexity under the +// repo's gocyclo gate. +func (s *Service) validateCreateAPIKeyRequest(user *User, password, name string, permissions []Permission, expiresAt *time.Time) error { if !s.verifyPassword(password, user.PasswordHash) { return fmt.Errorf("%w", ErrAPIKeyInvalidPassword) } if name == "" { return fmt.Errorf("API key name is required") } + // Reject unscoped keys outright: an unscoped key used to inherit the + // owner's full permission set (computeEffectivePermissionsFromAuthCtx), + // which made "leave scope blank" the most permissive option in the UI. + // Requiring at least one explicit permission forces scoping to be a + // deliberate choice (issue #61). + if len(permissions) == 0 { + return ErrEmptyAPIKeyPermissions + } // ExpiresAt is mandatory and capped: a nil value used to mean "never // expires", which let a single leaked key outlive any credential rotation. if expiresAt == nil { @@ -518,13 +538,17 @@ func (s *Service) RecordUsage(ctx context.Context, keyID string, delta int64) er // computeEffectivePermissionsFromAuthCtx returns the subset of key permissions // that the owner's authCtx also grants at the action/resource level, keeping -// the key's own constraint limits. If the key has no specific permissions the -// owner's full permission set is returned (key inherits owner). The result -// carries the key's constraints, not the owner's; callers that need both -// sources must check ownerAuthCtx.Permissions independently. +// the key's own constraint limits. If the key has no specific permissions it +// authorizes nothing: an unscoped key authenticates the owner but grants no +// permissions (deny-by-default, issue #61), rather than inheriting the +// owner's full permission set. That inheritance was a privilege-escalation +// trap -- a long-lived unscoped key silently became admin-capable the moment +// its owner joined the Administrators group. The result carries the key's +// constraints, not the owner's; callers that need both sources must check +// ownerAuthCtx.Permissions independently. func computeEffectivePermissionsFromAuthCtx(key *UserAPIKey, authCtx *AuthContext) []Permission { if len(key.Permissions) == 0 { - return authCtx.Permissions + return []Permission{} } effectivePerms := make([]Permission, 0, len(key.Permissions)) for _, keyPerm := range key.Permissions { @@ -538,11 +562,11 @@ func computeEffectivePermissionsFromAuthCtx(key *UserAPIKey, authCtx *AuthContex // ComputeEffectivePermissions computes the intersection of API key permissions and user permissions. // This ensures an API key cannot grant more permissions than the user has. // -// Administrators-group members carry {admin, *}: with no key-specific -// permissions their full {admin, *} context is returned, and a scoped admin -// key's permissions all pass the HasPermission intersection below, so the -// group-derived path preserves the previous role == admin behavior without a -// special case. +// An unscoped key (no key-specific permissions) grants nothing, regardless of +// the owner's group; see computeEffectivePermissionsFromAuthCtx. A scoped +// admin key's permissions all pass the HasPermission intersection below, so +// the group-derived path still lets a deliberately-admin-scoped key act with +// the owner's {admin, *} authority. func (s *Service) ComputeEffectivePermissions(ctx context.Context, apiKey *UserAPIKey, user *User) ([]Permission, error) { authCtx, err := s.GetAuthContext(ctx, user.ID) if err != nil { diff --git a/internal/auth/service_apikeys_api.go b/internal/auth/service_apikeys_api.go index 8e9336f16..1d3ae91fd 100644 --- a/internal/auth/service_apikeys_api.go +++ b/internal/auth/service_apikeys_api.go @@ -72,6 +72,21 @@ func effectiveLifetimeUsage(key *UserAPIKey) *int64 { return &total } +// normalizeAPIKeyPermissions returns perms unchanged, or []Permission{} when +// nil. CreateAPIKey now rejects new keys with zero permissions (issue #61), +// but a legacy key minted before that fix can still have a NULL permissions +// column, which the JSONB scan surfaces as a nil slice. Since +// APIKeyInfo.Permissions is no longer omitempty (issue #61), a raw nil would +// serialize as "permissions":null instead of the intended "permissions":[] +// -- both mean "no scope", but a client that expects the field to always be +// an array (never null) would otherwise have to special-case it. +func normalizeAPIKeyPermissions(perms []Permission) []Permission { + if perms == nil { + return []Permission{} + } + return perms +} + // API wrapper methods for API key operations // These methods return API-friendly types and handle type conversions @@ -93,12 +108,18 @@ type APIKeyInfo struct { // has never recorded a request or because its last window has closed. // Exposed so consumers know exactly which period RequestCountWindow // covers instead of assuming a true trailing "last 24h". - RequestCountWindowStart *time.Time `json:"request_count_window_start,omitempty"` - ID string `json:"id"` - Name string `json:"name"` - KeyPrefix string `json:"key_prefix"` - Permissions []Permission `json:"permissions,omitempty"` - IsActive bool `json:"is_active"` + RequestCountWindowStart *time.Time `json:"request_count_window_start,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + // Permissions is never omitempty: CreateAPIKey now rejects zero-length + // permissions outright (issue #61), so an empty array in a listing means + // a legacy key minted before that fix, distinguishable from a key that + // simply has no info populated. Hiding it behind omitempty made a + // zero-scope key indistinguishable from a fully-scoped one in + // listAPIKeys, which was itself part of the audit gap the issue raised. + Permissions []Permission `json:"permissions"` + IsActive bool `json:"is_active"` // Usage counters (issue #340/#344 deferred sub-task). RequestCountWindow // is a fixed/tumbling window count, not a true rolling 24h total, and is // zero once that window has closed -- see effectiveWindowUsage, @@ -207,7 +228,7 @@ func (s *Service) CreateAPIKeyAPI(ctx context.Context, userID string, req any) ( ID: keyInfo.ID, Name: keyInfo.Name, KeyPrefix: keyInfo.KeyPrefix, - Permissions: keyInfo.Permissions, + Permissions: normalizeAPIKeyPermissions(keyInfo.Permissions), ExpiresAt: keyInfo.ExpiresAt, CreatedAt: keyInfo.CreatedAt, LastUsedAt: keyInfo.LastUsedAt, @@ -237,7 +258,7 @@ func (s *Service) ListUserAPIKeysAPI(ctx context.Context, userID string) (any, e ID: key.ID, Name: key.Name, KeyPrefix: key.KeyPrefix, - Permissions: key.Permissions, + Permissions: normalizeAPIKeyPermissions(key.Permissions), ExpiresAt: key.ExpiresAt, CreatedAt: key.CreatedAt, LastUsedAt: key.LastUsedAt, @@ -339,8 +360,11 @@ func (s *Service) ValidateUserAPIKeyAPI(ctx context.Context, apiKey string) (*Us // action/resource against the key's effective permissions: the intersection // of the key's scoped permissions with the owning user's group-derived // permissions (ComputeEffectivePermissions). A key created without explicit -// permissions inherits the owner's full permission set. Returns the owning -// user's ID, the key's database ID, and whether the permission is held. +// permissions grants nothing (deny-by-default, issue #61); CreateAPIKey now +// rejects new unscoped keys outright, but a legacy zero-scope key minted +// before that fix still authenticates its owner while authorizing no +// action. Returns the owning user's ID, the key's database ID, and whether +// the permission is held. // The key ID is threaded to callers so they can pass it to // HasAPIKeyPermissionForConstraintsAPI without a redundant DB lookup. // diff --git a/internal/auth/service_apikeys_api_test.go b/internal/auth/service_apikeys_api_test.go index c7dd2322a..7c5af2cb1 100644 --- a/internal/auth/service_apikeys_api_test.go +++ b/internal/auth/service_apikeys_api_test.go @@ -162,6 +162,10 @@ func TestService_CreateAPIKeyAPI_CrossPackageType(t *testing.T) { Name: "My API Key", Password: testAPIKeyPassword, ExpiresAt: &expiresAt, + Permissions: []struct { + Action string `json:"action"` + Resource string `json:"resource"` + }{{Action: ActionView, Resource: ResourceRecommendations}}, } result, err := service.CreateAPIKeyAPI(ctx, "user-123", crossPkgReq) @@ -220,6 +224,34 @@ func TestService_ListUserAPIKeysAPI(t *testing.T) { mockStore.AssertExpectations(t) }) + // Regression for issue #61's follow-up: a key minted before CreateAPIKey + // started rejecting zero-length permissions can still have a NULL + // permissions column, which the store surfaces as a nil slice. + // APIKeyInfo.Permissions is no longer omitempty, so a raw nil would + // serialize as "permissions":null; normalizeAPIKeyPermissions makes it + // "permissions":[] like every post-fix key instead. + t.Run("normalizes a legacy nil-permissions key to an empty slice, not null", func(t *testing.T) { + mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + service := &Service{store: mockStore} + + keys := []*UserAPIKey{ + {ID: "legacy-key", UserID: "user-123", Name: "pre-fix key", Permissions: nil}, + } + user := &User{ID: "user-123", Email: "test@example.com", Active: true} + mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("ListAPIKeysByUser", ctx, "user-123").Return(keys, nil) + + result, err := service.ListUserAPIKeysAPI(ctx, "user-123") + require.NoError(t, err) + + resp, ok := result.(*APIListAPIKeysResponse) + require.True(t, ok) + require.Len(t, resp.APIKeys, 1) + assert.NotNil(t, resp.APIKeys[0].Permissions, "a legacy nil-permissions key must serialize as [] not null") + assert.Empty(t, resp.APIKeys[0].Permissions) + }) + // Regression: the per-row "Requests (window)" cell must not show a count // left over from a window that has already closed. The stored column // keeps its value until the key's next request, so the read path is what diff --git a/internal/auth/service_apikeys_test.go b/internal/auth/service_apikeys_test.go index 51e4efcb2..097b875ac 100644 --- a/internal/auth/service_apikeys_test.go +++ b/internal/auth/service_apikeys_test.go @@ -162,7 +162,8 @@ func TestService_CreateAPIKey(t *testing.T) { mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) - apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, []Permission{}, nil) + apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, + []Permission{{Action: ActionView, Resource: ResourceRecommendations}}, nil) assert.Error(t, err) assert.ErrorIs(t, err, ErrAPIKeyExpiresAtRequired) @@ -185,7 +186,8 @@ func TestService_CreateAPIKey(t *testing.T) { mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) - apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, []Permission{}, &tooFar) + apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, + []Permission{{Action: ActionView, Resource: ResourceRecommendations}}, &tooFar) assert.Error(t, err) assert.ErrorIs(t, err, ErrAPIKeyExpiresAtTooFar) @@ -211,7 +213,8 @@ func TestService_CreateAPIKey(t *testing.T) { mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) - apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, []Permission{}, &pastExpiry) + apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, + []Permission{{Action: ActionView, Resource: ResourceRecommendations}}, &pastExpiry) assert.Error(t, err) assert.ErrorIs(t, err, ErrAPIKeyExpiresAtInPast) @@ -263,18 +266,76 @@ func TestService_CreateAPIKey(t *testing.T) { Email: "test@example.com", Active: true, PasswordHash: testAPIKeyPasswordHash, + GroupIDs: []string{DefaultAdminGroupID}, + } + permissions := []Permission{ + {Action: ActionView, Resource: ResourceRecommendations}, } mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("GetGroup", ctx, DefaultAdminGroupID).Return(&Group{ + ID: DefaultAdminGroupID, + Permissions: []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, + }, nil) mockStore.On("CreateAPIKey", ctx, mock.AnythingOfType("*auth.UserAPIKey")).Return(assert.AnError) - apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, []Permission{}, &validExpiry) + apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, permissions, &validExpiry) assert.Error(t, err) assert.Empty(t, apiKey) assert.Nil(t, keyInfo) mockStore.AssertExpectations(t) }) + + t.Run("fail when permissions is empty", func(t *testing.T) { + // Regression test for issue #61: an unscoped key used to be accepted + // and would silently inherit the owner's full permission set at + // request time. CreateAPIKey must now reject it outright so scoping + // is a deliberate choice, not a default. + mockStore := new(MockStore) + service := &Service{store: mockStore} + + user := &User{ + ID: "user-123", + Email: "test@example.com", + Active: true, + PasswordHash: testAPIKeyPasswordHash, + } + + mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + + apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, []Permission{}, &validExpiry) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrEmptyAPIKeyPermissions) + assert.Empty(t, apiKey) + assert.Nil(t, keyInfo) + mockStore.AssertNotCalled(t, "CreateAPIKey", mock.Anything, mock.Anything) + mockStore.AssertExpectations(t) + }) + + t.Run("fail when permissions is nil", func(t *testing.T) { + mockStore := new(MockStore) + service := &Service{store: mockStore} + + user := &User{ + ID: "user-123", + Email: "test@example.com", + Active: true, + PasswordHash: testAPIKeyPasswordHash, + } + + mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + + apiKey, keyInfo, err := service.CreateAPIKey(ctx, "user-123", "Test Key", testAPIKeyPassword, nil, &validExpiry) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrEmptyAPIKeyPermissions) + assert.Empty(t, apiKey) + assert.Nil(t, keyInfo) + mockStore.AssertNotCalled(t, "CreateAPIKey", mock.Anything, mock.Anything) + mockStore.AssertExpectations(t) + }) } func TestService_ListUserAPIKeys(t *testing.T) { @@ -1006,7 +1067,10 @@ func TestService_ComputeEffectivePermissions(t *testing.T) { assert.Equal(t, scoped, permissions) }) - t.Run("admin with unscoped key returns full group permissions", func(t *testing.T) { + t.Run("admin with unscoped key returns no permissions (fail closed)", func(t *testing.T) { + // Regression test for issue #61: an unscoped key used to inherit the + // owner's full permission set, so an admin's unscoped key was + // silently admin-capable. It must now authorize nothing. mockStore := new(MockStore) service := &Service{store: mockStore} t.Cleanup(func() { mockStore.AssertExpectations(t) }) @@ -1019,7 +1083,7 @@ func TestService_ComputeEffectivePermissions(t *testing.T) { permissions, err := service.ComputeEffectivePermissions(ctx, apiKey, user) require.NoError(t, err) - assert.Equal(t, []Permission{{Action: ActionAdmin, Resource: ResourceAll}}, permissions) + assert.Empty(t, permissions) }) t.Run("zero-group user with unscoped key returns no permissions (fail closed)", func(t *testing.T) { @@ -1166,13 +1230,13 @@ func TestService_HasAPIKeyPermissionAPI(t *testing.T) { assert.False(t, has, "key must not grant permissions the owning user does not hold") }) - t.Run("unscoped key inherits the owner's group permissions", func(t *testing.T) { + t.Run("regression #61: unscoped key is denied (fail closed), not inherited from the owner", func(t *testing.T) { service := setup(t, nil) userID, _, has, err := service.HasAPIKeyPermissionAPI(ctx, rawKey, ActionCreate, ResourcePlans) require.NoError(t, err) assert.Equal(t, "user-123", userID) - assert.True(t, has) + assert.False(t, has, "unscoped key must not inherit the owner's group permissions") }) t.Run("invalid key fails closed with an error", func(t *testing.T) { diff --git a/internal/server/adapter_test.go b/internal/server/adapter_test.go index a6c9913ea..cb7ead618 100644 --- a/internal/server/adapter_test.go +++ b/internal/server/adapter_test.go @@ -399,9 +399,10 @@ func TestAuthServiceAdapter_CreateAPIKeyAPI(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour) result, err := adapter.CreateAPIKeyAPI(ctx, "user-1", auth.APICreateAPIKeyRequest{ - Name: "my-key", - Password: password, - ExpiresAt: &expiresAt, + Name: "my-key", + Password: password, + Permissions: []auth.Permission{{Action: auth.ActionView, Resource: auth.ResourceRecommendations}}, + ExpiresAt: &expiresAt, }) require.NoError(t, err) require.NotNil(t, result) diff --git a/internal/server/apikey_unscoped_permission_test.go b/internal/server/apikey_unscoped_permission_test.go new file mode 100644 index 000000000..e6bb0a140 --- /dev/null +++ b/internal/server/apikey_unscoped_permission_test.go @@ -0,0 +1,94 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/api" + "github.com/LeanerCloud/cloud-commitments-platform/internal/auth" + "github.com/aws/aws-lambda-go/events" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestHandleRequest_UnscopedAPIKey_DeniedOnAdminOwner is the regression test +// for issue #61: an API key created with zero explicit permissions used to +// inherit its owner's full permission set at request time +// (computeEffectivePermissionsFromAuthCtx), so an admin's unscoped key was +// silently admin-capable on every endpoint. It must now be denied. +// +// This drives the real, wired-together stack -- a real auth.Service over a +// mock store, behind a real api.Handler -- through HandleRequest so the +// assertion covers the actual request path (API-key auth middleware -> +// requirePermission -> ComputeEffectivePermissions), not just the service +// method in isolation. See apikey_usage_booking_test.go for why package +// server is required for this: api's mock auth service stubs the permission +// check itself, which would make the regression structurally invisible. +func TestHandleRequest_UnscopedAPIKey_DeniedOnAdminOwner(t *testing.T) { + rawKey := "unscoped-key-owned-by-admin" // #nosec G101 -- test fixture, not a real credential + hash := sha256.Sum256([]byte(rawKey)) + keyHash := base64.RawURLEncoding.EncodeToString(hash[:]) + + key := &auth.UserAPIKey{ + ID: "key-unscoped-1", + UserID: "admin-1", + Name: "unscoped key", + KeyHash: keyHash, + IsActive: true, + // No Permissions set: this is the exact "leave scope blank" shape + // the issue describes. CreateAPIKey now rejects minting one, but a + // key minted before that fix (or inserted directly, e.g. by a + // migration) can still exist and authenticate. + } + user := &auth.User{ + ID: "admin-1", + Email: "admin@example.com", + Active: true, + GroupIDs: []string{auth.DefaultAdminGroupID}, + } + adminGroup := &auth.Group{ + ID: auth.DefaultAdminGroupID, + Name: "Administrators", + Permissions: []auth.Permission{{Action: auth.ActionAdmin, Resource: auth.ResourceAll}}, + } + + mockStore := new(auth.MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + mockStore.On("GetAPIKeyByHash", mock.Anything, keyHash).Return(key, nil) + mockStore.On("GetUserByID", mock.Anything, "admin-1").Return(user, nil) + mockStore.On("GetGroup", mock.Anything, auth.DefaultAdminGroupID).Return(adminGroup, nil) + mockStore.On("RecordAPIKeyUsage", mock.Anything, "key-unscoped-1", mock.Anything).Return(nil).Maybe() + + service := auth.NewService(auth.ServiceConfig{ + Store: mockStore, + SessionDuration: time.Hour, + CSRFKey: auth.TestCSRFKey(), + }) + handler := api.NewHandler(api.HandlerConfig{ + AuthService: newAuthServiceAdapter(service), + CORSAllowedOrigin: "https://dashboard.example.com", + }) + + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"x-api-key": rawKey}, + RequestContext: events.LambdaFunctionURLRequestContext{ + HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{ + Method: "GET", + Path: "/api/api-keys", + }, + }, + } + + resp, err := handler.HandleRequest(context.Background(), req) + + require.NoError(t, err) + // Pre-fix this returned 200: the key's owner is an Administrators-group + // member, and an unscoped key inherited {admin, *} wholesale even though + // the key itself was never granted view:api-keys. + require.Equal(t, 403, resp.StatusCode, + "an unscoped API key must not inherit its admin owner's permissions; body: %s", resp.Body) +} diff --git a/internal/server/apikey_usage_booking_test.go b/internal/server/apikey_usage_booking_test.go index ade8281a6..c17c4fec6 100644 --- a/internal/server/apikey_usage_booking_test.go +++ b/internal/server/apikey_usage_booking_test.go @@ -61,8 +61,10 @@ type usageBookingHarness struct { } // newUsageBookingHarness builds the harness. groupPermissions are granted to -// the key owner's group; the API key itself carries no explicit permissions, -// so it inherits the owner's set. +// both the key owner's group and the key itself: an unscoped key now +// authorizes nothing (issue #61's fail-closed fix), so the harness scopes the +// key explicitly to the same permissions to exercise the booking path rather +// than the now-denied inheritance path. func newUsageBookingHarness(t *testing.T, groupPermissions []auth.Permission) *usageBookingHarness { t.Helper() @@ -74,11 +76,12 @@ func newUsageBookingHarness(t *testing.T, groupPermissions []auth.Permission) *u keyHash := usageBookingKeyHash() key := &auth.UserAPIKey{ - ID: "key-1", - UserID: "user-1", - Name: "usage booking harness", - KeyHash: keyHash, - IsActive: true, + ID: "key-1", + UserID: "user-1", + Name: "usage booking harness", + KeyHash: keyHash, + IsActive: true, + Permissions: groupPermissions, } user := &auth.User{ ID: "user-1",