Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 86 additions & 27 deletions src/lib/presign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default async function presign(options: Record<string, unknown>) {
);
const format = getFormat(options, 'url');
const accessKeyFlag = getOption<string>(options, ['access-key', 'accessKey']);
const selectFlag = getOption<boolean>(options, ['select']);

const config = await getStorageConfig();

Expand All @@ -49,7 +50,7 @@ export default async function presign(options: Record<string, unknown>) {
// 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') {
Expand All @@ -58,7 +59,11 @@ export default async function presign(options: Record<string, unknown>) {
);
}

accessKeyId = await resolveAccessKeyInteractively(bucket);
if (selectFlag) {
accessKeyId = await resolveAccessKeyWithPrompt(bucket, method);
} else {
accessKeyId = await resolveAccessKeyAuto(bucket, method);
}
}

if (!accessKeyId) {
Expand Down Expand Up @@ -98,15 +103,7 @@ export default async function presign(options: Record<string, unknown>) {
process.exit(0);
}

async function resolveAccessKeyInteractively(
targetBucket: string
): Promise<string> {
if (!process.stdin.isTTY) {
exitWithError(
'Presigning requires an access key. Pass --access-key tid_...'
);
}

async function fetchAccessKeys(): Promise<AccessKey[]> {
const authClient = getAuthClient();
const accessToken = await authClient.getAccessToken();
const selectedOrg = getSelectedOrganization();
Expand All @@ -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<string> {
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 <name>"'
);
}

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 <name>\n` +
`Then assign: tigris access-keys assign <id> --bucket ${targetBucket} --role Editor`
);
}

console.error(`Using access key: ${match.name} (${match.id})`);
return match.id;
}

async function resolveAccessKeyWithPrompt(
targetBucket: string,
method: string
): Promise<string> {
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 <name>"'
);
}

// Filter to active keys that match the operation
const matchingKeys = activeKeys.filter((key) =>
keyMatchesOperation(key, targetBucket, method)
);

let candidates: AccessKey[];
Expand All @@ -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 <name>"'
);
}

console.error(
`No access keys with explicit access to bucket "${targetBucket}" found. Showing all active keys.`
);
Expand All @@ -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})`,
})),
Expand Down
5 changes: 4 additions & 1 deletion src/specs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
114 changes: 114 additions & 0 deletions test/lib/presign.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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);
});
});
});
1 change: 1 addition & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down