diff --git a/package.json b/package.json index 7cdc2d7..992ab44 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "lint:fix": "eslint src --fix", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", - "test": "vitest run test/utils test/auth test/cli-core.test.ts test/specs-completeness.test.ts", + "test": "vitest run --exclude test/cli.test.ts", "test:watch": "vitest", "test:all": "vitest run", "test:integration": "vitest run test/cli.test.ts", diff --git a/src/lib/presign.ts b/src/lib/presign.ts index fcea32a..fdb4fde 100644 --- a/src/lib/presign.ts +++ b/src/lib/presign.ts @@ -36,6 +36,7 @@ export default async function presign(options: Record) { ); const format = getFormat(options, 'url'); const accessKeyFlag = getOption(options, ['access-key', 'accessKey']); + const selectFlag = getOption(options, ['select']); const config = await getStorageConfig(); @@ -49,7 +50,7 @@ export default async function presign(options: Record) { // 2. Credentials/env/configured login has an access key accessKeyId = config.accessKeyId; } else { - // 3. OAuth login — need to resolve an access key interactively + // 3. OAuth login — need to resolve an access key const loginMethod = await getLoginMethod(); if (loginMethod !== 'oauth') { @@ -58,7 +59,11 @@ export default async function presign(options: Record) { ); } - accessKeyId = await resolveAccessKeyInteractively(bucket); + if (selectFlag) { + accessKeyId = await resolveAccessKeyWithPrompt(bucket, method); + } else { + accessKeyId = await resolveAccessKeyAuto(bucket, method); + } } if (!accessKeyId) { @@ -98,15 +103,7 @@ export default async function presign(options: Record) { process.exit(0); } -async function resolveAccessKeyInteractively( - targetBucket: string -): Promise { - if (!process.stdin.isTTY) { - exitWithError( - 'Presigning requires an access key. Pass --access-key tid_...' - ); - } - +async function fetchAccessKeys(): Promise { const authClient = getAuthClient(); const accessToken = await authClient.getAccessToken(); const selectedOrg = getSelectedOrganization(); @@ -130,11 +127,83 @@ async function resolveAccessKeyInteractively( ); } - // Filter to active keys that have access to the target bucket - const matchingKeys = data.accessKeys.filter( - (key: AccessKey) => - key.status === 'active' && - key.roles?.some((r) => r.bucket === targetBucket || r.bucket === '*') + return data.accessKeys; +} + +export function keyMatchesOperation( + key: AccessKey, + targetBucket: string, + method: string +): boolean { + if (!key.roles) return false; + + return key.roles.some((r) => { + // NamespaceAdmin has access to everything + if (r.role === 'NamespaceAdmin') return true; + + // Role must target this bucket or wildcard + if (r.bucket !== targetBucket && r.bucket !== '*') return false; + + // For put: need Editor + if (method === 'put') return r.role === 'Editor'; + + // For get: Editor or ReadOnly + return r.role === 'Editor' || r.role === 'ReadOnly'; + }); +} + +async function resolveAccessKeyAuto( + targetBucket: string, + method: string +): Promise { + const keys = await fetchAccessKeys(); + const activeKeys = keys.filter((key) => key.status === 'active'); + + if (activeKeys.length === 0) { + exitWithError( + 'No active access keys found. Create one with "tigris access-keys create "' + ); + } + + const match = activeKeys.find((key) => + keyMatchesOperation(key, targetBucket, method) + ); + + if (!match) { + const requiredRole = method === 'put' ? 'Editor' : 'Editor or ReadOnly'; + exitWithError( + `No access key with ${requiredRole} access to bucket "${targetBucket}" found.\n` + + `Create one: tigris access-keys create \n` + + `Then assign: tigris access-keys assign --bucket ${targetBucket} --role Editor` + ); + } + + console.error(`Using access key: ${match.name} (${match.id})`); + return match.id; +} + +async function resolveAccessKeyWithPrompt( + targetBucket: string, + method: string +): Promise { + if (!process.stdin.isTTY) { + exitWithError( + 'Interactive selection requires a TTY. Omit --select to auto-resolve, or pass --access-key tid_...' + ); + } + + const keys = await fetchAccessKeys(); + const activeKeys = keys.filter((key) => key.status === 'active'); + + if (activeKeys.length === 0) { + exitWithError( + 'No active access keys found. Create one with "tigris access-keys create "' + ); + } + + // Filter to active keys that match the operation + const matchingKeys = activeKeys.filter((key) => + keyMatchesOperation(key, targetBucket, method) ); let candidates: AccessKey[]; @@ -143,16 +212,6 @@ async function resolveAccessKeyInteractively( candidates = matchingKeys; } else { // Fall back to all active keys with a warning - const activeKeys = data.accessKeys.filter( - (key: AccessKey) => key.status === 'active' - ); - - if (activeKeys.length === 0) { - exitWithError( - 'No active access keys found. Create one with "tigris access-keys create "' - ); - } - console.error( `No access keys with explicit access to bucket "${targetBucket}" found. Showing all active keys.` ); @@ -172,7 +231,7 @@ async function resolveAccessKeyInteractively( type: 'select', name: 'selectedKey', message: 'Select an access key for presigning:', - choices: candidates.map((key: AccessKey) => ({ + choices: candidates.map((key) => ({ name: key.id, message: `${key.name} (${key.id})`, })), diff --git a/src/specs.yaml b/src/specs.yaml index 6c0e8d5..7f10187 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -431,7 +431,10 @@ commands: alias: e default: '3600' - name: access-key - description: Access key ID to use for signing. If not provided, resolved from credentials or prompted interactively + description: Access key ID to use for signing. If not provided, resolved from credentials or auto-selected + - name: select + description: Interactively select an access key (OAuth only) + type: flag - name: format description: Output format options: [url, json] diff --git a/test/lib/presign.test.ts b/test/lib/presign.test.ts new file mode 100644 index 0000000..529bfc2 --- /dev/null +++ b/test/lib/presign.test.ts @@ -0,0 +1,114 @@ +import type { AccessKey } from '@tigrisdata/iam'; +import { describe, expect, it } from 'vitest'; + +import { keyMatchesOperation } from '../../src/lib/presign.js'; + +function makeKey( + roles: AccessKey['roles'] = [], + overrides: Partial = {} +): AccessKey { + return { + id: 'tid_test', + name: 'test-key', + createdAt: new Date(), + status: 'active', + roles, + ...overrides, + }; +} + +describe('keyMatchesOperation', () => { + describe('get method', () => { + it('matches Editor on target bucket', () => { + const key = makeKey([{ bucket: 'my-bucket', role: 'Editor' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'get')).toBe(true); + }); + + it('matches ReadOnly on target bucket', () => { + const key = makeKey([{ bucket: 'my-bucket', role: 'ReadOnly' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'get')).toBe(true); + }); + + it('matches Editor on wildcard bucket', () => { + const key = makeKey([{ bucket: '*', role: 'Editor' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'get')).toBe(true); + }); + + it('matches ReadOnly on wildcard bucket', () => { + const key = makeKey([{ bucket: '*', role: 'ReadOnly' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'get')).toBe(true); + }); + + it('does not match Editor on different bucket', () => { + const key = makeKey([{ bucket: 'other-bucket', role: 'Editor' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'get')).toBe(false); + }); + }); + + describe('put method', () => { + it('matches Editor on target bucket', () => { + const key = makeKey([{ bucket: 'my-bucket', role: 'Editor' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'put')).toBe(true); + }); + + it('does not match ReadOnly on target bucket', () => { + const key = makeKey([{ bucket: 'my-bucket', role: 'ReadOnly' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'put')).toBe(false); + }); + + it('matches Editor on wildcard bucket', () => { + const key = makeKey([{ bucket: '*', role: 'Editor' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'put')).toBe(true); + }); + + it('does not match ReadOnly on wildcard bucket', () => { + const key = makeKey([{ bucket: '*', role: 'ReadOnly' }]); + expect(keyMatchesOperation(key, 'my-bucket', 'put')).toBe(false); + }); + }); + + describe('NamespaceAdmin', () => { + it('matches for get regardless of bucket', () => { + const key = makeKey([{ bucket: '*', role: 'NamespaceAdmin' }]); + expect(keyMatchesOperation(key, 'any-bucket', 'get')).toBe(true); + }); + + it('matches for put regardless of bucket', () => { + const key = makeKey([{ bucket: '*', role: 'NamespaceAdmin' }]); + expect(keyMatchesOperation(key, 'any-bucket', 'put')).toBe(true); + }); + + it('matches even with a specific bucket value', () => { + const key = makeKey([{ bucket: 'some-bucket', role: 'NamespaceAdmin' }]); + expect(keyMatchesOperation(key, 'other-bucket', 'get')).toBe(true); + }); + }); + + describe('edge cases', () => { + it('returns false when roles is undefined', () => { + const key = makeKey(undefined); + expect(keyMatchesOperation(key, 'my-bucket', 'get')).toBe(false); + }); + + it('returns false when roles is empty', () => { + const key = makeKey([]); + expect(keyMatchesOperation(key, 'my-bucket', 'get')).toBe(false); + }); + + it('matches if any role satisfies the operation', () => { + const key = makeKey([ + { bucket: 'other-bucket', role: 'ReadOnly' }, + { bucket: 'my-bucket', role: 'Editor' }, + ]); + expect(keyMatchesOperation(key, 'my-bucket', 'put')).toBe(true); + }); + + it('does not match when no role satisfies the operation', () => { + const key = makeKey([ + { bucket: 'other-bucket', role: 'Editor' }, + { bucket: 'my-bucket', role: 'ReadOnly' }, + ]); + expect(keyMatchesOperation(key, 'my-bucket', 'put')).toBe(false); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 017d465..84dac24 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + reporter: 'verbose', include: ['test/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], exclude: ['node_modules', 'dist'], setupFiles: ['test/setup.ts'],