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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
"dependencies": {
"@aws-sdk/credential-providers": "^3.1024.0",
"@smithy/shared-ini-file-loader": "^4.4.7",
"@tigrisdata/iam": "^1.4.1",
"@tigrisdata/iam": "^2.1.0",
"@tigrisdata/storage": "^3.0.0",
"commander": "^14.0.3",
"enquirer": "^2.4.1",
Expand Down
83 changes: 83 additions & 0 deletions src/lib/access-keys/attach-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import enquirer from 'enquirer';
const { prompt } = enquirer;
import { getIAMConfig } from '@auth/iam.js';
import {
attachPolicyToAccessKey,
listPolicies,
listPoliciesForAccessKey,
} from '@tigrisdata/iam';
import { failWithError } from '@utils/exit.js';
import { requireInteractive } from '@utils/interactive.js';
import { msg, printStart, printSuccess } from '@utils/messages.js';
import { getFormat, getOption } from '@utils/options.js';

const context = msg('access-keys', 'attach-policy');

export default async function attachPolicy(options: Record<string, unknown>) {
printStart(context);

const format = getFormat(options);

const id = getOption<string>(options, ['id']);
let policyArn = getOption<string>(options, ['policyArn', 'policy-arn']);

if (!id) {
failWithError(context, 'Access key ID is required');
}

const config = await getIAMConfig(context);

if (!policyArn) {
requireInteractive('Use --policy-arn to specify the policy ARN');

// Fetch all policies and assigned policies in parallel
const [allPoliciesResult, assignedResult] = await Promise.all([
listPolicies({ config }),
listPoliciesForAccessKey(id, { config }),
]);

if (allPoliciesResult.error) {
failWithError(context, allPoliciesResult.error);
}

if (assignedResult.error) {
failWithError(context, assignedResult.error);
}

const assignedNames = new Set(assignedResult.data.policies);
const available = allPoliciesResult.data.policies.filter(
(p) => !assignedNames.has(p.name)
Comment thread
designcode marked this conversation as resolved.
);

if (available.length === 0) {
failWithError(
context,
'No unassigned policies available. All policies are already attached to this access key.'
);
}

const { selected } = await prompt<{ selected: string }>({
type: 'select',
name: 'selected',
message: 'Select a policy to attach:',
choices: available.map((p) => ({
name: p.resource,
message: `${p.name} (${p.resource})`,
})),
});

policyArn = selected;
}

const { error } = await attachPolicyToAccessKey(id, policyArn, { config });

if (error) {
failWithError(context, error);
}

if (format === 'json') {
console.log(JSON.stringify({ action: 'attached', id, policyArn }));
}

printSuccess(context);
}
92 changes: 92 additions & 0 deletions src/lib/access-keys/detach-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import enquirer from 'enquirer';
const { prompt } = enquirer;
import { getIAMConfig } from '@auth/iam.js';
import {
detachPolicyFromAccessKey,
listPolicies,
listPoliciesForAccessKey,
} from '@tigrisdata/iam';
import { failWithError } from '@utils/exit.js';
import { confirm, requireInteractive } from '@utils/interactive.js';
import { msg, printStart, printSuccess } from '@utils/messages.js';
import { getFormat, getOption } from '@utils/options.js';

const context = msg('access-keys', 'detach-policy');

export default async function detachPolicy(options: Record<string, unknown>) {
printStart(context);

const format = getFormat(options);

const id = getOption<string>(options, ['id']);
let policyArn = getOption<string>(options, ['policyArn', 'policy-arn']);
const force = getOption<boolean>(options, ['yes', 'y', 'force']);

if (!id) {
failWithError(context, 'Access key ID is required');
}

const config = await getIAMConfig(context);

if (!policyArn) {
requireInteractive('Use --policy-arn to specify the policy ARN');

// Fetch assigned policy names and all policies to resolve ARNs
const [assignedResult, allPoliciesResult] = await Promise.all([
listPoliciesForAccessKey(id, { config }),
listPolicies({ config }),
]);

if (assignedResult.error) {
failWithError(context, assignedResult.error);
}

if (allPoliciesResult.error) {
failWithError(context, allPoliciesResult.error);
}

const assignedNames = new Set(assignedResult.data.policies);
const assigned = allPoliciesResult.data.policies.filter((p) =>
assignedNames.has(p.name)
);

if (assigned.length === 0) {
failWithError(context, 'No policies are attached to this access key.');
}

const { selected } = await prompt<{ selected: string }>({
type: 'select',
name: 'selected',
message: 'Select a policy to detach:',
choices: assigned.map((p) => ({
name: p.resource,
message: `${p.name} (${p.resource})`,
})),
});

policyArn = selected;
}

if (!force) {
requireInteractive('Use --yes to skip confirmation');
const confirmed = await confirm(
`Detach policy '${policyArn}' from access key '${id}'?`
);
if (!confirmed) {
console.log('Aborted');
return;
}
}

const { error } = await detachPolicyFromAccessKey(id, policyArn, { config });

if (error) {
failWithError(context, error);
}

if (format === 'json') {
console.log(JSON.stringify({ action: 'detached', id, policyArn }));
}

printSuccess(context);
}
73 changes: 73 additions & 0 deletions src/lib/access-keys/list-policies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { getIAMConfig } from '@auth/iam.js';
import { listPoliciesForAccessKey } from '@tigrisdata/iam';
import { failWithError } from '@utils/exit.js';
import { formatPaginatedOutput } from '@utils/format.js';
import {
msg,
printEmpty,
printPaginationHint,
printStart,
printSuccess,
} from '@utils/messages.js';
import { getFormat, getPaginationOptions } from '@utils/options.js';
import { getOption } from '@utils/options.js';

const context = msg('access-keys', 'list-policies');

export default async function listPolicies(options: Record<string, unknown>) {
printStart(context);

const format = getFormat(options);
const { limit, pageToken } = getPaginationOptions(options);

const id = getOption<string>(options, ['id']);

if (!id) {
failWithError(context, 'Access key ID is required');
}

const config = await getIAMConfig(context);

const { data, error } = await listPoliciesForAccessKey(id, {
...(limit !== undefined ? { limit } : {}),
...(pageToken ? { paginationToken: pageToken } : {}),
config,
});

if (error) {
failWithError(context, error);
}

if (!data.policies || data.policies.length === 0) {
printEmpty(context);
return;
}

const policies = data.policies.map((name) => ({ policy: name }));

const columns = [
{
key: 'policy',
header: policies.length > 1 ? 'Attached Policies' : 'Attached Policy',
},
];

const nextToken = data.paginationToken || undefined;

const output = formatPaginatedOutput(
policies,
format!,
'policies',
'policy',
columns,
{ paginationToken: nextToken }
);

console.log(output);

if (format !== 'json' && format !== 'xml') {
printPaginationHint(nextToken);
}

printSuccess(context, { count: policies.length });
}
65 changes: 65 additions & 0 deletions src/lib/access-keys/rotate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getIAMConfig } from '@auth/iam.js';
import { rotateAccessKey } from '@tigrisdata/iam';
import {
failWithError,
getSuccessNextActions,
printNextActions,
} from '@utils/exit.js';
import { confirm, requireInteractive } from '@utils/interactive.js';
import { msg, printStart, printSuccess } from '@utils/messages.js';
import { getFormat, getOption } from '@utils/options.js';

const context = msg('access-keys', 'rotate');

export default async function rotate(options: Record<string, unknown>) {
printStart(context);

const format = getFormat(options);

const id = getOption<string>(options, ['id']);
const force = getOption<boolean>(options, ['yes', 'y', 'force']);

if (!id) {
failWithError(context, 'Access key ID is required');
}

if (!force) {
requireInteractive('Use --yes to skip confirmation');
const confirmed = await confirm(
`Rotate access key '${id}'? The current secret will be invalidated.`
);
if (!confirmed) {
console.log('Aborted');
return;
}
}

const config = await getIAMConfig(context);

const { data, error } = await rotateAccessKey(id, { config });

if (error) {
failWithError(context, error);
}

if (format === 'json') {
const nextActions = getSuccessNextActions(context, { id: data.id });
const output: Record<string, unknown> = {
action: 'rotated',
id: data.id,
secret: data.newSecret,
};
if (nextActions.length > 0) output.nextActions = nextActions;
console.log(JSON.stringify(output));
} else {
console.log(` Access Key ID: ${data.id}`);
console.log(` New Secret Access Key: ${data.newSecret}`);
console.log('');
console.log(
' Save these credentials securely. The secret will not be shown again.'
);
}

printSuccess(context);
printNextActions(context, { id: data.id });
}
Loading