From 7a4411cf0d52bd3d111749d384d59c745919aa24 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Mon, 12 Oct 2026 19:36:14 +0200 Subject: [PATCH] feat(iam): add default permissions on access keys Co-Authored-By: Claude Fable 5.1 Assisted-by: Claude Fable 5.1 via Claude Code --- .changeset/cli-access-key-scope.md | 5 + .changeset/iam-access-key-scope.md | 5 + packages/cli/README.md | 25 ++++- packages/cli/src/lib/access-keys/create.ts | 14 ++- .../lib/access-keys/default-permissions.ts | 41 +++++++ packages/cli/src/lib/access-keys/get.ts | 7 ++ packages/cli/src/lib/access-keys/list.ts | 15 ++- packages/cli/src/lib/access-keys/set.ts | 44 ++++++++ packages/cli/src/specs.yaml | 30 ++++- .../cli/test/lib/access-keys/create.test.ts | 50 ++++++++- packages/cli/test/lib/access-keys/set.test.ts | 106 ++++++++++++++++++ packages/iam/src/index.ts | 6 + .../iam/src/lib/access-key/create.test.ts | 98 ++++++++++++++++ packages/iam/src/lib/access-key/create.ts | 29 ++++- packages/iam/src/lib/access-key/get.test.ts | 86 ++++++++++++++ packages/iam/src/lib/access-key/get.ts | 7 +- packages/iam/src/lib/access-key/list.ts | 7 +- .../iam/src/lib/access-key/set-scope.test.ts | 81 +++++++++++++ packages/iam/src/lib/access-key/set-scope.ts | 74 ++++++++++++ packages/iam/src/lib/access-key/types.ts | 31 +++++ 20 files changed, 751 insertions(+), 10 deletions(-) create mode 100644 .changeset/cli-access-key-scope.md create mode 100644 .changeset/iam-access-key-scope.md create mode 100644 packages/cli/src/lib/access-keys/default-permissions.ts create mode 100644 packages/cli/src/lib/access-keys/set.ts create mode 100644 packages/cli/test/lib/access-keys/set.test.ts create mode 100644 packages/iam/src/lib/access-key/create.test.ts create mode 100644 packages/iam/src/lib/access-key/get.test.ts create mode 100644 packages/iam/src/lib/access-key/set-scope.test.ts create mode 100644 packages/iam/src/lib/access-key/set-scope.ts diff --git a/.changeset/cli-access-key-scope.md b/.changeset/cli-access-key-scope.md new file mode 100644 index 00000000..1e0a5c3e --- /dev/null +++ b/.changeset/cli-access-key-scope.md @@ -0,0 +1,5 @@ +--- +"@tigrisdata/cli": minor +--- + +`tigris access-keys create --default-permissions disable` creates a key without the default permissions — it can do only what its roles and policies explicitly allow — and `tigris access-keys set --default-permissions enable|disable` changes the setting on an existing key, leaving its bucket roles as they are. This is the console's "Enable default permissions" toggle; JSON output carries it as the SDK's `scope` (`standard` or `no_default_allow`). `access-keys get` and `access-keys list` show the setting for every key. diff --git a/.changeset/iam-access-key-scope.md b/.changeset/iam-access-key-scope.md new file mode 100644 index 00000000..17abdac7 --- /dev/null +++ b/.changeset/iam-access-key-scope.md @@ -0,0 +1,5 @@ +--- +"@tigrisdata/iam": minor +--- + +Access keys gain a scope. `createAccessKey(name, { scope })` sets it at creation and the new `setAccessKeyScope(id, scope)` changes it on an existing key, leaving its bucket roles as they are. `standard` (the default) grants the implicit default-allowed operations such as creating and listing buckets on top of what the key's policies and bucket roles grant; `no_default_allow` limits the key to what they explicitly allow. `getAccessKey` and `listAccessKeys` report it as `scope`, and `ACCESS_KEY_SCOPES` lists the valid values. diff --git a/packages/cli/README.md b/packages/cli/README.md index 8c300184..2ee056be 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1403,8 +1403,9 @@ Create, list, inspect, delete, and assign roles to access keys. Access keys are | `tigris access-keys list` (l) | List all access keys in the current organization | | `tigris access-keys create` (c) | Create a new access key, optionally scoped to buckets, and export its credentials to a dotenv file or your shell. Returns the key ID and secret (shown only once) | | `tigris access-keys delete` (d) | Permanently delete one or more access keys by ID. This revokes all access immediately | -| `tigris access-keys get` (g) | Show details for an access key including its name, creation date, and assigned bucket roles | +| `tigris access-keys get` (g) | Show details for an access key including its name, creation date, default permissions, and assigned bucket roles | | `tigris access-keys assign` (a) | Assign per-bucket roles to an access key. Pair each --bucket with a --role (Editor, ReadWrite, or ReadOnly), or use --admin for org-wide access | +| `tigris access-keys set` (s) | Update settings on an existing access key — currently its default permissions, the console's "Enable default permissions" toggle. Bucket roles are left as they are | | `tigris access-keys rotate` (r) | Rotate an access key's secret. The current secret is immediately invalidated and a new one is returned (shown only once) | | `tigris access-keys attach-policy` (ap) | Attach an IAM policy to an access key. If no policy ARN is provided, shows interactive selection of available policies | | `tigris access-keys detach-policy` (dp) | Detach an IAM policy from an access key. If no policy ARN is provided, shows interactive selection of attached policies | @@ -1442,6 +1443,7 @@ tigris access-keys create [flags] | `-b, --bucket` | Bucket name (can specify multiple, comma-separated). Each bucket is paired positionally with a --role value | | `-r, --role` | Role to assign (can specify multiple, comma-separated). Each role pairs with the corresponding --bucket value | | `--admin` | Grant admin access to all buckets in the organization | +| `--default-permissions` | Whether the key also gets the default-allowed operations (such as creating and listing buckets) on top of its roles and policies — the console's "Enable default permissions" toggle. Enabled unless disabled | | `--env` | Write the credentials to a dotenv file, merging into it when it already exists. Defaults to ./.env. The secret is then not printed | | `--export` | Print the credentials as shell "export" lines and nothing else on stdout, for eval "$(tigris access-keys create --export)" | | `--for` | Which SDK the credentials are for. --env and --export then use that SDK's variable names. tigris writes TIGRIS_STORAGE_*; aws writes AWS_* plus Tigris endpoints and region (default: tigris) | @@ -1450,6 +1452,7 @@ tigris access-keys create [flags] ```bash tigris access-keys create my-ci-key tigris access-keys create my-app --bucket my-app-bucket --role Editor --env +tigris access-keys create ci --bucket my-app-bucket --role ReadOnly --default-permissions disable eval "$(tigris access-keys create dev --bucket my-app-bucket --role Editor --export)" ``` @@ -1474,7 +1477,7 @@ tigris access-keys delete tid_AaBb,tid_CcDd --yes #### `tigris access-keys get` (g) -Show details for an access key including its name, creation date, and assigned bucket roles +Show details for an access key including its name, creation date, default permissions, and assigned bucket roles ``` tigris access-keys get @@ -1508,6 +1511,24 @@ tigris access-keys assign tid_AaBb --admin tigris access-keys assign tid_AaBb --revoke-roles ``` +#### `tigris access-keys set` (s) + +Update settings on an existing access key — currently its default permissions, the console's "Enable default permissions" toggle. Bucket roles are left as they are + +``` +tigris access-keys set [flags] +``` + +| Flag | Description | +|------|-------------| +| `--default-permissions` | Whether the key also gets the default-allowed operations (such as creating and listing buckets) on top of its roles and policies — the console's "Enable default permissions" toggle. Enabled unless disabled | + +**Examples:** +```bash +tigris access-keys set tid_AaBb --default-permissions disable +tigris access-keys set tid_AaBb --default-permissions enable +``` + #### `tigris access-keys rotate` (r) Rotate an access key's secret. The current secret is immediately invalidated and a new one is returned (shown only once) diff --git a/packages/cli/src/lib/access-keys/create.ts b/packages/cli/src/lib/access-keys/create.ts index 8aec797d..b717b672 100644 --- a/packages/cli/src/lib/access-keys/create.ts +++ b/packages/cli/src/lib/access-keys/create.ts @@ -23,7 +23,10 @@ import { printSuccess, } from '@utils/messages.js'; import { getFormat, getOption } from '@utils/options.js'; - +import { + describeDefaultPermissions, + getDefaultPermissions, +} from './default-permissions.js'; import { buildRoleAssignments, getRoleRequest, @@ -65,6 +68,8 @@ export default async function create(options: Record) { ); } + const scope = getDefaultPermissions(context, options); + const assignments = requestsRoles(request) ? buildRoleAssignments(context, request, ' (or use --admin)') : undefined; @@ -90,6 +95,7 @@ export default async function create(options: Record) { const { data, error } = await createAccessKey(name, { config, bucketsRole: assignments, + ...(scope ? { scope } : {}), }); if (error) { @@ -151,6 +157,7 @@ export default async function create(options: Record) { secret: data.secret, }; if (assignments) output.roles = assignments; + if (data.scope) output.scope = data.scope; if (writtenPath) output.env = writtenPath; if (gitignored !== undefined) output.gitignored = gitignored; if (envError) output.envError = envError.message; @@ -172,6 +179,11 @@ export default async function create(options: Record) { ` Roles: ${assignments.map((a) => `${a.role} on ${a.bucket}`).join(', ')}` ); } + if (data.scope) { + console.log( + ` Default permissions: ${describeDefaultPermissions(data.scope)}` + ); + } if (!writtenPath) { console.log(''); console.log( diff --git a/packages/cli/src/lib/access-keys/default-permissions.ts b/packages/cli/src/lib/access-keys/default-permissions.ts new file mode 100644 index 00000000..ab80af4b --- /dev/null +++ b/packages/cli/src/lib/access-keys/default-permissions.ts @@ -0,0 +1,41 @@ +import type { AccessKeyScope } from '@tigrisdata/iam'; +import { failWithError } from '@utils/exit.js'; +import type { MessageContext } from '@utils/messages.js'; +import { getOption } from '@utils/options.js'; + +/** + * The console's "Enable default permissions" toggle, as the SDK's scope: + * enabled is the `standard` scope, disabled is `no_default_allow`. + */ +const SCOPE_BY_SETTING = { + enable: 'standard', + disable: 'no_default_allow', +} as const satisfies Record; + +type Setting = keyof typeof SCOPE_BY_SETTING; + +const SETTINGS = Object.keys(SCOPE_BY_SETTING) as Setting[]; + +/** `--default-permissions` as the scope it stands for; undefined when absent. */ +export function getDefaultPermissions( + context: MessageContext, + options: Record +): AccessKeyScope | undefined { + const setting = getOption(options, [ + 'default-permissions', + 'defaultPermissions', + ]); + if (setting === undefined) return undefined; + if (!SETTINGS.includes(setting as Setting)) { + failWithError( + context, + `Invalid --default-permissions "${setting}". Valid values are: ${SETTINGS.join(', ')}` + ); + } + return SCOPE_BY_SETTING[setting as Setting]; +} + +/** How the setting reads back: enabled for `standard`, disabled otherwise. */ +export function describeDefaultPermissions(scope: AccessKeyScope): string { + return scope === 'standard' ? 'enabled' : 'disabled'; +} diff --git a/packages/cli/src/lib/access-keys/get.ts b/packages/cli/src/lib/access-keys/get.ts index 3ecf97f9..3473b191 100644 --- a/packages/cli/src/lib/access-keys/get.ts +++ b/packages/cli/src/lib/access-keys/get.ts @@ -4,6 +4,8 @@ import { failWithError } from '@utils/exit.js'; import { msg, printStart, printSuccess } from '@utils/messages.js'; import { getFormat, getOption } from '@utils/options.js'; +import { describeDefaultPermissions } from './default-permissions.js'; + const context = msg('access-keys', 'get'); export default async function get(options: Record) { @@ -33,6 +35,11 @@ export default async function get(options: Record) { console.log(` Status: ${data.status}`); console.log(` Created: ${data.createdAt}`); console.log(` Organization: ${data.organizationId}`); + if (data.scope) { + console.log( + ` Default permissions: ${describeDefaultPermissions(data.scope)}` + ); + } if (data.roles && data.roles.length > 0) { console.log(` Roles:`); diff --git a/packages/cli/src/lib/access-keys/list.ts b/packages/cli/src/lib/access-keys/list.ts index 6e6b71d0..4596b6ee 100644 --- a/packages/cli/src/lib/access-keys/list.ts +++ b/packages/cli/src/lib/access-keys/list.ts @@ -11,6 +11,8 @@ import { } from '@utils/messages.js'; import { getFormat, getPaginationOptions } from '@utils/options.js'; +import { describeDefaultPermissions } from './default-permissions.js'; + const context = msg('access-keys', 'list'); export default async function list(options: Record) { @@ -40,19 +42,30 @@ export default async function list(options: Record) { name: key.name, id: key.id, status: key.status, + scope: key.scope, created: key.createdAt, })); + // JSON and XML carry the SDK's `scope`; the table reads like the console. + const machineReadable = format === 'json' || format === 'xml'; + const rows = machineReadable + ? keys + : keys.map((key) => ({ + ...key, + scope: key.scope && describeDefaultPermissions(key.scope), + })); + const columns = [ { key: 'name', header: 'Name' }, { key: 'id', header: 'ID' }, { key: 'status', header: 'Status' }, + { key: 'scope', header: 'Default permissions' }, { key: 'created', header: 'Created' }, ]; const nextToken = data.paginationToken || undefined; - const output = formatPaginatedOutput(keys, format!, 'keys', 'key', columns, { + const output = formatPaginatedOutput(rows, format!, 'keys', 'key', columns, { paginationToken: nextToken, }); diff --git a/packages/cli/src/lib/access-keys/set.ts b/packages/cli/src/lib/access-keys/set.ts new file mode 100644 index 00000000..fb8404d6 --- /dev/null +++ b/packages/cli/src/lib/access-keys/set.ts @@ -0,0 +1,44 @@ +import { getIAMConfig } from '@auth/iam.js'; +import { setAccessKeyScope } from '@tigrisdata/iam'; +import { failWithError } from '@utils/exit.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +import { + describeDefaultPermissions, + getDefaultPermissions, +} from './default-permissions.js'; + +const context = msg('access-keys', 'set'); + +export default async function set(options: Record) { + printStart(context); + + const format = getFormat(options); + const id = getOption(options, ['id']); + const scope = getDefaultPermissions(context, options); + + if (!id) { + failWithError(context, 'Access key ID is required'); + } + if (!scope) { + failWithError( + context, + 'Nothing to change: pass --default-permissions enable or disable' + ); + } + + const config = await getIAMConfig(context); + + const { error } = await setAccessKeyScope(id, scope, { config }); + + if (error) { + failWithError(context, error); + } + + if (format === 'json') { + console.log(JSON.stringify({ action: 'updated', id, scope })); + } + + printSuccess(context, { id, setting: describeDefaultPermissions(scope) }); +} diff --git a/packages/cli/src/specs.yaml b/packages/cli/src/specs.yaml index 9a6688c9..2b77394a 100644 --- a/packages/cli/src/specs.yaml +++ b/packages/cli/src/specs.yaml @@ -106,6 +106,11 @@ definitions: name: admin description: Grant admin access to all buckets in the organization type: flag + key_default_permissions_argument: &key_default_permissions_argument + name: default-permissions + description: Whether the key also gets the default-allowed operations (such as creating and listing buckets) on top of its roles and policies — the console's "Enable default permissions" toggle. Enabled unless disabled + helpText: Default permissions beyond the key's roles and policies (enable or disable) + options: [enable, disable] global_arguments: &global_arguments - name: format @@ -2269,6 +2274,7 @@ commands: examples: - "tigris access-keys create my-ci-key" - "tigris access-keys create my-app --bucket my-app-bucket --role Editor --env" + - "tigris access-keys create ci --bucket my-app-bucket --role ReadOnly --default-permissions disable" - 'eval "$(tigris access-keys create dev --bucket my-app-bucket --role Editor --export)"' messages: onStart: 'Creating access key...' @@ -2287,6 +2293,7 @@ commands: - *key_bucket_argument - *key_role_argument - *key_admin_argument + - *key_default_permissions_argument - name: env description: Write the credentials to a dotenv file, merging into it when it already exists. Defaults to ./.env. The secret is then not printed helpText: Write the credentials to a dotenv file (default ./.env) @@ -2326,8 +2333,8 @@ commands: type: flag description: Skip confirmation prompts (alias for --yes) - name: get - description: Show details for an access key including its name, creation date, and assigned bucket roles - helpText: Show an access key's details and bucket roles + description: Show details for an access key including its name, creation date, default permissions, and assigned bucket roles + helpText: Show an access key's details, permissions, and bucket roles alias: g examples: - "tigris access-keys get tid_AaBbCcDdEeFf" @@ -2371,6 +2378,25 @@ commands: - name: revoke-roles description: Revoke all bucket roles from the access key type: flag + - name: set + description: Update settings on an existing access key — currently its default permissions, the console's "Enable default permissions" toggle. Bucket roles are left as they are + helpText: Update settings on an existing access key + alias: s + examples: + - "tigris access-keys set tid_AaBb --default-permissions disable" + - "tigris access-keys set tid_AaBb --default-permissions enable" + messages: + onStart: 'Updating access key...' + onSuccess: "Default permissions {{setting}} for access key '{{id}}'" + onFailure: 'Failed to update access key' + arguments: + - name: id + description: Access key ID + type: positional + required: true + examples: + - tid_AaBbCcDdEeFf + - *key_default_permissions_argument - name: rotate description: Rotate an access key's secret. The current secret is immediately invalidated and a new one is returned (shown only once) helpText: Rotate an access key's secret diff --git a/packages/cli/test/lib/access-keys/create.test.ts b/packages/cli/test/lib/access-keys/create.test.ts index 49177684..dd765a84 100644 --- a/packages/cli/test/lib/access-keys/create.test.ts +++ b/packages/cli/test/lib/access-keys/create.test.ts @@ -59,7 +59,10 @@ beforeEach(() => { vi.spyOn(process, 'exit').mockImplementation(() => { throw new ExitSignal(); }); - vi.mocked(createAccessKey).mockResolvedValue({ data: created }); + // The SDK echoes the requested scope, or standard when none was asked for. + vi.mocked(createAccessKey).mockImplementation(async (_name, options) => ({ + data: { ...created, scope: options?.scope ?? 'standard' }, + })); vi.mocked(resolveEndpoints).mockResolvedValue({ storage: 'https://t3.storage.dev', iam: 'https://iam.storageapi.dev', @@ -82,6 +85,8 @@ describe('access-keys create', () => { bucketsRole: undefined, }); expect(stdout.join('\n')).toContain('Secret Access Key: tsec_XxYy'); + // The setting is reported even when it was left at its default. + expect(stdout.join('\n')).toContain('Default permissions: enabled'); expect(stdout.join('\n')).toContain('will not be shown again'); // The plain path is what it always was: no endpoint lookup at all. expect(resolveEndpoints).not.toHaveBeenCalled(); @@ -235,6 +240,49 @@ describe('access-keys create', () => { expect(stderr.join('\n')).toContain('Invalid --for "gcp"'); }); + it('turns --default-permissions disable into the no_default_allow scope', async () => { + await run({ + bucket: 'my-app', + role: 'ReadOnly', + defaultPermissions: 'disable', + }); + expect(createAccessKey).toHaveBeenCalledWith('my-app', { + config: {}, + bucketsRole: [{ bucket: 'my-app', role: 'ReadOnly' }], + scope: 'no_default_allow', + }); + expect(stdout.join('\n')).toContain('Default permissions: disabled'); + }); + + it('accepts the kebab-case key and enable', async () => { + await run({ 'default-permissions': 'enable' }); + expect(createAccessKey).toHaveBeenCalledWith('my-app', { + config: {}, + bucketsRole: undefined, + scope: 'standard', + }); + expect(stdout.join('\n')).toContain('Default permissions: enabled'); + }); + + it("reports the SDK's scope in JSON, flag or no flag", async () => { + await run({ defaultPermissions: 'disable', json: true }); + expect(JSON.parse(stdout[0]).scope).toBe('no_default_allow'); + + stdout = []; + await run({ json: true }); + expect(JSON.parse(stdout[0]).scope).toBe('standard'); + }); + + it('rejects an unknown --default-permissions before creating anything', async () => { + await expect(run({ defaultPermissions: 'off' })).rejects.toThrow( + ExitSignal + ); + expect(stderr.join('\n')).toContain( + 'Invalid --default-permissions "off". Valid values are: enable, disable' + ); + expect(createAccessKey).not.toHaveBeenCalled(); + }); + it('reports roles and the written file in JSON, and keeps the secret', async () => { await run({ bucket: 'my-app', role: 'Editor', env: true, json: true }); const output = JSON.parse(stdout[0]); diff --git a/packages/cli/test/lib/access-keys/set.test.ts b/packages/cli/test/lib/access-keys/set.test.ts new file mode 100644 index 00000000..439fcee5 --- /dev/null +++ b/packages/cli/test/lib/access-keys/set.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as YAML from 'yaml'; + +vi.mock('@auth/iam.js', () => ({ + getIAMConfig: vi.fn(async () => ({})), +})); +vi.mock('@tigrisdata/iam', () => ({ + setAccessKeyScope: vi.fn(async () => ({ data: undefined })), +})); + +import { setAccessKeyScope } from '@tigrisdata/iam'; +import set from '../../../src/lib/access-keys/set.js'; +import type { Specs } from '../../../src/types.js'; +import { setSpecs } from '../../../src/utils/specs.js'; + +setSpecs( + YAML.parse(readFileSync(join(process.cwd(), 'src/specs.yaml'), 'utf8'), { + schema: 'core', + }) as Specs +); + +class ExitSignal extends Error {} + +let stdout: string[]; +let stderr: string[]; + +beforeEach(() => { + // Clears calls, not implementations: per-test overrides below use `…Once`. + vi.clearAllMocks(); + stdout = []; + stderr = []; + vi.spyOn(console, 'log').mockImplementation((...args) => { + stdout.push(args.join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args) => { + stderr.push(args.join(' ')); + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new ExitSignal(); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('access-keys set', () => { + it('disables the default permissions and reports the scope in JSON', async () => { + await set({ id: 'tid_a', defaultPermissions: 'disable', json: true }); + + expect(setAccessKeyScope).toHaveBeenCalledWith( + 'tid_a', + 'no_default_allow', + { config: {} } + ); + expect(JSON.parse(stdout[0])).toEqual({ + action: 'updated', + id: 'tid_a', + scope: 'no_default_allow', + }); + }); + + it('enables them again', async () => { + await set({ id: 'tid_a', 'default-permissions': 'enable' }); + expect(setAccessKeyScope).toHaveBeenCalledWith('tid_a', 'standard', { + config: {}, + }); + }); + + it('rejects an unknown value before touching the key', async () => { + await expect( + set({ id: 'tid_a', defaultPermissions: 'off' }) + ).rejects.toThrow(ExitSignal); + expect(stderr.join('\n')).toContain( + 'Invalid --default-permissions "off". Valid values are: enable, disable' + ); + expect(setAccessKeyScope).not.toHaveBeenCalled(); + }); + + it('needs a setting to change', async () => { + await expect(set({ id: 'tid_a' })).rejects.toThrow(ExitSignal); + expect(stderr.join('\n')).toContain( + 'Nothing to change: pass --default-permissions enable or disable' + ); + expect(setAccessKeyScope).not.toHaveBeenCalled(); + }); + + it('requires the id', async () => { + await expect(set({ defaultPermissions: 'enable' })).rejects.toThrow( + ExitSignal + ); + expect(stderr.join('\n')).toContain('Access key ID is required'); + }); + + it('fails with the gateway error', async () => { + vi.mocked(setAccessKeyScope).mockResolvedValueOnce({ + error: new Error('key not found'), + }); + await expect( + set({ id: 'tid_a', defaultPermissions: 'disable' }) + ).rejects.toThrow(ExitSignal); + expect(stderr.join('\n')).toContain('key not found'); + }); +}); diff --git a/packages/iam/src/index.ts b/packages/iam/src/index.ts index 93f7fd9c..3916bcc4 100644 --- a/packages/iam/src/index.ts +++ b/packages/iam/src/index.ts @@ -45,10 +45,16 @@ export { type RotateAccessKeyResponse, rotateAccessKey, } from './lib/access-key/rotate'; +export { + type SetAccessKeyScopeOptions, + setAccessKeyScope, +} from './lib/access-key/set-scope'; export { ACCESS_KEY_ROLES, + ACCESS_KEY_SCOPES, type AccessKey, type AccessKeyRole, + type AccessKeyScope, type BucketRoleAssignment, type BucketScopedRole, } from './lib/access-key/types'; diff --git a/packages/iam/src/lib/access-key/create.test.ts b/packages/iam/src/lib/access-key/create.test.ts new file mode 100644 index 00000000..bd02ef26 --- /dev/null +++ b/packages/iam/src/lib/access-key/create.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../http-client', async (importOriginal) => ({ + ...(await importOriginal()), + createIAMClient: vi.fn(), +})); + +import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; +import { createAccessKey } from './create'; +import type { AccessKeyScope } from './types'; + +const request = vi.fn(); +const config = { accessKeyId: 'k', secretAccessKey: 's' }; + +function sentBody() { + const [{ body }] = request.mock.calls[0] as [{ body: URLSearchParams }]; + return JSON.parse(body.get('Req') ?? '{}'); +} + +beforeEach(() => { + vi.clearAllMocks(); + request.mockResolvedValue({ + data: { + CreateAccessKeyResult: { + AccessKey: { + AccessKeyId: 'tid_new', + SecretAccessKey: 'tsec_new', + UserName: 'my-key', + CreateDate: '2026-10-12T00:00:00Z', + }, + }, + }, + }); + vi.mocked(createIAMClient).mockReturnValue({ + data: { request }, + } as unknown as ReturnType); +}); + +describe('createAccessKey', () => { + it('posts CreateAccessKeyWithBucketsRole with the roles', async () => { + const result = await createAccessKey('my-key', { + config, + bucketsRole: [{ bucket: 'a', role: 'Editor' }], + }); + + const [{ method, path }] = request.mock.calls[0] as [ + { method: string; path: string }, + ]; + expect(method).toBe('POST'); + expect(path).toBe(IAM_ENDPOINTS.createAccessKey); + expect(sentBody()).toMatchObject({ + name: 'my-key', + buckets_role: [{ bucket: 'a', role: 'Editor' }], + }); + expect(result.data).toMatchObject({ + id: 'tid_new', + secret: 'tsec_new', + name: 'my-key', + roles: [{ bucket: 'a', role: 'Editor' }], + }); + }); + + it('leaves the scope to the gateway default unless asked', async () => { + const result = await createAccessKey('my-key', { config }); + expect(sentBody()).not.toHaveProperty('access_key_scope'); + expect(result.data?.scope).toBe('standard'); + }); + + it('sends the numeric scope when one is given', async () => { + const result = await createAccessKey('my-key', { + config, + scope: 'no_default_allow', + }); + expect(sentBody()).toMatchObject({ access_key_scope: 1 }); + expect(result.data?.scope).toBe('no_default_allow'); + + request.mockClear(); + await createAccessKey('my-key', { config, scope: 'standard' }); + expect(sentBody()).toMatchObject({ access_key_scope: 0 }); + }); + + it('rejects an unknown scope before any request', async () => { + const result = await createAccessKey('my-key', { + config, + scope: 'no_default_allowed' as AccessKeyScope, + }); + expect(result.error?.message).toBe( + 'Invalid scope "no_default_allowed". Valid scopes are: standard, no_default_allow' + ); + expect(request).not.toHaveBeenCalled(); + }); + + it("returns the gateway's error", async () => { + request.mockResolvedValueOnce({ error: new Error('LimitExceeded') }); + const result = await createAccessKey('my-key', { config }); + expect(result.error?.message).toBe('LimitExceeded'); + }); +}); diff --git a/packages/iam/src/lib/access-key/create.ts b/packages/iam/src/lib/access-key/create.ts index f9c1c779..77f85549 100644 --- a/packages/iam/src/lib/access-key/create.ts +++ b/packages/iam/src/lib/access-key/create.ts @@ -1,10 +1,18 @@ import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; import type { TigrisIAMConfig, TigrisIAMResponse } from '../types'; -import type { AccessKey, BucketRoleAssignment } from './types'; +import { + ACCESS_KEY_SCOPES, + type AccessKey, + type AccessKeyScope, + type BucketRoleAssignment, + toWireScope, +} from './types'; export type CreateAccessKeyOptions = { config?: TigrisIAMConfig; bucketsRole?: BucketRoleAssignment[]; + /** Defaults to `standard` on the gateway when omitted. */ + scope?: AccessKeyScope; }; type IAMCreateAccessKeyResponse = { @@ -22,6 +30,20 @@ export async function createAccessKey( name: string, options?: CreateAccessKeyOptions ): Promise> { + // An unknown scope must not fall through to the gateway's default: that + // would silently create a key with the permissions the caller tried to + // withhold. + if ( + options?.scope !== undefined && + !ACCESS_KEY_SCOPES.includes(options.scope) + ) { + return { + error: new Error( + `Invalid scope "${options.scope}". Valid scopes are: ${ACCESS_KEY_SCOPES.join(', ')}` + ), + }; + } + const { data: client, error } = createIAMClient(options?.config); if (error) { return { error }; @@ -34,6 +56,9 @@ export async function createAccessKey( req_uuid: crypto.randomUUID(), name, buckets_role: options?.bucketsRole ?? [], + ...(options?.scope !== undefined + ? { access_key_scope: toWireScope(options.scope) } + : {}), }) ); @@ -73,6 +98,8 @@ export async function createAccessKey( bucket: role.bucket, role: role.role, })), + // Not echoed by the gateway; what was asked for is what the key got. + scope: options?.scope ?? 'standard', }, }; } diff --git a/packages/iam/src/lib/access-key/get.test.ts b/packages/iam/src/lib/access-key/get.test.ts new file mode 100644 index 00000000..6036bf30 --- /dev/null +++ b/packages/iam/src/lib/access-key/get.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../http-client', async (importOriginal) => ({ + ...(await importOriginal()), + createIAMClient: vi.fn(), +})); + +import { createIAMClient } from '../http-client'; +import { getAccessKey } from './get'; +import { listAccessKeys } from './list'; + +const request = vi.fn(); +const config = { accessKeyId: 'k', secretAccessKey: 's' }; + +function key(extra: Record = {}) { + return { + access_key_id: 'tid_a', + created_at: '2026-10-12T00:00:00Z', + creator: 'c', + human_creator: 'h', + namespace_id: 'org-a', + status: 'active', + username: 'my-key', + buckets_role: [{ bucket: 'a', role: 'Editor' }], + ...extra, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createIAMClient).mockReturnValue({ + data: { request }, + } as unknown as ReturnType); +}); + +describe('access key scope in get and list', () => { + it('maps the numeric scope to its name', async () => { + request.mockResolvedValueOnce({ + data: { + IsTruncated: false, + Marker: '', + Keys: [key({ access_key_scope: 1 })], + }, + }); + const got = await getAccessKey('tid_a', { config }); + expect(got.data).toMatchObject({ + id: 'tid_a', + name: 'my-key', + roles: [{ bucket: 'a', role: 'Editor' }], + scope: 'no_default_allow', + }); + + request.mockResolvedValueOnce({ + data: { + IsTruncated: false, + Marker: '', + Keys: [key({ access_key_scope: 0 }), key({ access_key_scope: 1 })], + }, + }); + const listed = await listAccessKeys({ config }); + expect(listed.data?.accessKeys.map((k) => k.scope)).toEqual([ + 'standard', + 'no_default_allow', + ]); + }); + + it('treats a missing scope as standard, the value the gateway leaves out', async () => { + request.mockResolvedValueOnce({ + data: { IsTruncated: false, Marker: '', Keys: [key()] }, + }); + const got = await getAccessKey('tid_a', { config }); + expect(got.data?.scope).toBe('standard'); + }); + + it('leaves a scope it does not know undefined', async () => { + request.mockResolvedValueOnce({ + data: { + IsTruncated: false, + Marker: '', + Keys: [key({ access_key_scope: 7 })], + }, + }); + const got = await getAccessKey('tid_a', { config }); + expect(got.data?.scope).toBeUndefined(); + }); +}); diff --git a/packages/iam/src/lib/access-key/get.ts b/packages/iam/src/lib/access-key/get.ts index e5cacb63..4800e96f 100644 --- a/packages/iam/src/lib/access-key/get.ts +++ b/packages/iam/src/lib/access-key/get.ts @@ -1,6 +1,10 @@ import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; import type { TigrisIAMConfig, TigrisIAMResponse } from '../types'; -import type { AccessKey, IAMAccessKeysResponse } from './types'; +import { + type AccessKey, + fromWireScope, + type IAMAccessKeysResponse, +} from './types'; export type GetAccessKeyOptions = { config?: TigrisIAMConfig; @@ -53,6 +57,7 @@ export async function getAccessKey( bucket: role.bucket, role: role.role, })), + scope: fromWireScope(key.access_key_scope), }, }; } diff --git a/packages/iam/src/lib/access-key/list.ts b/packages/iam/src/lib/access-key/list.ts index 49468bdd..8063d580 100644 --- a/packages/iam/src/lib/access-key/list.ts +++ b/packages/iam/src/lib/access-key/list.ts @@ -1,6 +1,10 @@ import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; import type { TigrisIAMConfig, TigrisIAMResponse } from '../types'; -import type { AccessKey, IAMAccessKeysResponse } from './types'; +import { + type AccessKey, + fromWireScope, + type IAMAccessKeysResponse, +} from './types'; export type ListAccessKeysOptions = { limit?: number; @@ -65,6 +69,7 @@ export async function listAccessKeys( bucket: role.bucket, role: role.role, })), + scope: fromWireScope(key.access_key_scope), })) ?? [], }, }; diff --git a/packages/iam/src/lib/access-key/set-scope.test.ts b/packages/iam/src/lib/access-key/set-scope.test.ts new file mode 100644 index 00000000..dd61d6a0 --- /dev/null +++ b/packages/iam/src/lib/access-key/set-scope.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../http-client', async (importOriginal) => ({ + ...(await importOriginal()), + createIAMClient: vi.fn(), +})); + +import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; +import { setAccessKeyScope } from './set-scope'; +import type { AccessKeyScope } from './types'; + +const request = vi.fn(); +const config = { accessKeyId: 'k', secretAccessKey: 's' }; + +function sentBody() { + const [{ body }] = request.mock.calls[0] as [{ body: URLSearchParams }]; + return JSON.parse(body.get('Req') ?? '{}'); +} + +beforeEach(() => { + vi.clearAllMocks(); + request.mockResolvedValue({ + data: { status: 'success', operation: 'update', message: '' }, + }); + vi.mocked(createIAMClient).mockReturnValue({ + data: { request }, + } as unknown as ReturnType); +}); + +describe('setAccessKeyScope', () => { + it('sends the numeric scope and nothing about roles', async () => { + const result = await setAccessKeyScope('tid_a', 'no_default_allow', { + config, + }); + + expect(result).toEqual({ data: undefined }); + expect(createIAMClient).toHaveBeenCalledWith(config); + const [{ method, path }] = request.mock.calls[0] as [ + { method: string; path: string }, + ]; + expect(method).toBe('POST'); + expect(path).toBe(IAM_ENDPOINTS.assignAccessKeys); + // No buckets_role key at all: the gateway keeps the existing roles. + expect(sentBody()).toEqual({ id: 'tid_a', access_key_scope: 1 }); + }); + + it('encodes standard as 0', async () => { + await setAccessKeyScope('tid_a', 'standard', { config }); + expect(sentBody()).toEqual({ id: 'tid_a', access_key_scope: 0 }); + }); + + it('rejects an unknown scope before any request', async () => { + const result = await setAccessKeyScope('tid_a', 'admin' as AccessKeyScope, { + config, + }); + expect(result.error?.message).toBe( + 'Invalid scope "admin". Valid scopes are: standard, no_default_allow' + ); + expect(request).not.toHaveBeenCalled(); + }); + + it('requires an id', async () => { + const result = await setAccessKeyScope('', 'standard', { config }); + expect(result.error?.message).toBe('Access key ID is required'); + expect(request).not.toHaveBeenCalled(); + }); + + it("returns the gateway's failure message", async () => { + request.mockResolvedValueOnce({ + data: { status: 'error', operation: 'update', message: 'key not found' }, + }); + const result = await setAccessKeyScope('tid_a', 'standard', { config }); + expect(result.error?.message).toBe('key not found'); + }); + + it('returns a transport failure as { error } instead of throwing', async () => { + request.mockRejectedValueOnce(new TypeError('fetch failed')); + const result = await setAccessKeyScope('tid_a', 'standard', { config }); + expect(result.error?.message).toBe('fetch failed'); + }); +}); diff --git a/packages/iam/src/lib/access-key/set-scope.ts b/packages/iam/src/lib/access-key/set-scope.ts new file mode 100644 index 00000000..da0abe9d --- /dev/null +++ b/packages/iam/src/lib/access-key/set-scope.ts @@ -0,0 +1,74 @@ +import { createIAMClient, IAM_ENDPOINTS } from '../http-client'; +import type { TigrisIAMConfig, TigrisIAMResponse } from '../types'; +import { ACCESS_KEY_SCOPES, type AccessKeyScope, toWireScope } from './types'; + +export type SetAccessKeyScopeOptions = { + config?: TigrisIAMConfig; +}; + +/** + * Change an access key's scope — whether it gets the default-allowed + * operations (`standard`) or only what its policies and bucket roles grant + * (`no_default_allow`). The key's bucket roles are left as they are. + */ +export async function setAccessKeyScope( + id: string, + scope: AccessKeyScope, + options?: SetAccessKeyScopeOptions +): Promise> { + if (!id) { + return { error: new Error('Access key ID is required') }; + } + if (!ACCESS_KEY_SCOPES.includes(scope)) { + return { + error: new Error( + `Invalid scope "${scope}". Valid scopes are: ${ACCESS_KEY_SCOPES.join(', ')}` + ), + }; + } + + const { data: client, error } = createIAMClient(options?.config); + if (error) { + return { error }; + } + + // No buckets_role in the body: the gateway then keeps the existing roles. + const formData = new URLSearchParams(); + formData.append( + 'Req', + JSON.stringify({ id, access_key_scope: toWireScope(scope) }) + ); + + try { + const response = await client.request< + URLSearchParams, + { message: string; operation: string; status: string } + >({ + method: 'POST', + path: IAM_ENDPOINTS.assignAccessKeys, + body: formData, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }); + + if (response.error || !response.data) { + return { error: response.error ?? new Error('No response from server') }; + } + + if (response.data.status !== 'success') { + return { + error: new Error( + response.data.message ?? 'Failed to set the access key scope' + ), + }; + } + + return { data: undefined }; + } catch (error) { + return { + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/packages/iam/src/lib/access-key/types.ts b/packages/iam/src/lib/access-key/types.ts index 56002ac0..7dd868f3 100644 --- a/packages/iam/src/lib/access-key/types.ts +++ b/packages/iam/src/lib/access-key/types.ts @@ -27,6 +27,35 @@ export type BucketRoleAssignment = { role: AccessKeyRole; }; +/** + * Whether an access key gets the implicit default-allowed operations (such as + * creating or listing buckets) on top of what its policies and bucket roles + * grant. `standard`, the default, does; `no_default_allow` limits the key to + * what its policies and roles explicitly allow. + */ +export const ACCESS_KEY_SCOPES = ['standard', 'no_default_allow'] as const; + +export type AccessKeyScope = (typeof ACCESS_KEY_SCOPES)[number]; + +/** The gateway encodes the scope as a number: 0 standard, 1 no_default_allow. */ +const WIRE_SCOPES: Record = { + standard: 0, + no_default_allow: 1, +}; + +export function toWireScope(scope: AccessKeyScope): number { + return WIRE_SCOPES[scope]; +} + +/** + * The gateway leaves the field out at its zero value, so an absent scope is + * `standard`. Undefined only for a value this version does not know. + */ +export function fromWireScope(value: unknown): AccessKeyScope | undefined { + if (value === undefined || value === null) return 'standard'; + return ACCESS_KEY_SCOPES.find((scope) => WIRE_SCOPES[scope] === value); +} + export type AccessKey = { id: string; name: string; @@ -35,6 +64,7 @@ export type AccessKey = { status: 'active' | 'inactive'; organizationId?: string; roles?: BucketRoleAssignment[]; + scope?: AccessKeyScope; }; /** Raw IAM `ListAccessKeys` response, shared by `list` and `get`. */ @@ -50,5 +80,6 @@ export type IAMAccessKeysResponse = { status: 'active' | 'inactive'; username: string; buckets_role: BucketRoleAssignment[]; + access_key_scope?: number; }[]; };