From f0fd98c07682291e963aafa12c9bca04adeef782 Mon Sep 17 00:00:00 2001 From: Ryan Ciehanski Date: Sun, 27 Sep 2026 00:00:00 -0500 Subject: [PATCH] feat(access-key): create read-only and app-scoped keys --- script/command-executor.ts | 41 ++++++++++++++++++---- script/command-parser.ts | 26 ++++++++++++++ script/management-sdk.ts | 7 +++- script/types.ts | 2 ++ script/types/cli.ts | 2 ++ script/types/rest-definitions.ts | 6 ++++ test/cli.ts | 58 +++++++++++++++++++++++++++++++- test/management-sdk.ts | 24 +++++++++++++ 8 files changed, 158 insertions(+), 8 deletions(-) diff --git a/script/command-executor.ts b/script/command-executor.ts index 91e4754..1086fbb 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -120,11 +120,40 @@ export const confirm = (message: string = "Are you sure?"): Promise => }); }; +function formatKeyScope(scopes: string[] | undefined, appNames: string[] | null | undefined): string { + const access = !scopes || !scopes.length || scopes.includes("full") ? "full access" : "read only"; + const apps = appNames && appNames.length ? appNames.join(", ") : "all apps"; + return `${access}, ${apps}`; +} + function accessKeyAdd(command: cli.IAccessKeyAddCommand): Promise { - return sdk.addAccessKey(command.name, command.ttl).then((accessKey: AccessKey) => { - log(`Successfully created the "${command.name}" access key: ${accessKey.key}`); - log("Make sure to save this key value somewhere safe, since you won't be able to view it from the CLI again!"); - }); + // The API scopes by app id but people type names, so resolve first and fail before minting a key + // that would reach nothing. + const resolveAppIds = (): Promise => { + if (!command.appNames || !command.appNames.length) return Q(undefined); + return sdk.getApps().then((apps: App[]) => + command.appNames.map((appName: string): string => { + const app = apps.find((candidate: App) => candidate.name === appName); + if (!app) { + throw new Error(`App "${appName}" was not found in your account, so the access key was not created.`); + } + if (!app.id) { + throw new Error(`The server did not return an id for app "${appName}". Limiting keys to apps needs a newer DeployPulse API.`); + } + return app.id; + }) + ); + }; + + return resolveAppIds().then((appIds: string[] | undefined) => + sdk.addAccessKey(command.name, command.ttl, command.scopes, appIds).then((accessKey: AccessKey) => { + log(`Successfully created the "${command.name}" access key: ${accessKey.key}`); + if (command.scopes || appIds) { + log(`Scope: ${formatKeyScope(command.scopes, command.appNames)}`); + } + log("Make sure to save this key value somewhere safe, since you won't be able to view it from the CLI again!"); + }) + ); } function accessKeyPatch(command: cli.IAccessKeyPatchCommand): Promise { @@ -1127,7 +1156,7 @@ function printAccessKeys(format: string, keys: AccessKey[]): void { if (format === "json") { printJson(keys); } else if (format === "table") { - printTable(["Name", "Created", "Expires"], (dataSource: any[]): void => { + printTable(["Name", "Created", "Expires", "Scope"], (dataSource: any[]): void => { const now = new Date().getTime(); function isExpired(key: AccessKey): boolean { @@ -1135,7 +1164,7 @@ function printAccessKeys(format: string, keys: AccessKey[]): void { } function keyToTableRow(key: AccessKey, dim: boolean): string[] { - const row: string[] = [key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires)]; + const row: string[] = [key.name, key.createdTime ? formatDate(key.createdTime) : "", formatDate(key.expires), formatKeyScope(key.scopes, key.appNames)]; if (dim) { row.forEach((col: string, index: number) => { diff --git a/script/command-parser.ts b/script/command-parser.ts index 2101af3..72e43ae 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -63,6 +63,24 @@ function accessKeyAdd(commandName: string, yargs: yargs.Argv): void { demand: false, description: "Duration string which specifies the amount of time that the access key should remain valid for (e.g 5m, 60d, 1y)", type: "string", + }) + .example( + "access-key " + commandName + ' "MyApp CI" --app MyApp-iOS --app MyApp-Android', + "Creates a key that can only reach those two apps" + ) + .example("access-key " + commandName + ' "Metrics bot" --scope read', "Creates a read-only key") + .option("scope", { + // No default: yargs validates `choices` against the default too, and omitting the field lets the + // server apply its own. + choices: ["full", "read"], + demand: false, + description: 'What the key may do: "full" (default) or "read" (GET requests only)', + type: "string", + }) + .option("app", { + demand: false, + description: "Limit the key to this app. Repeat for several apps. Omit for all apps.", + type: "array", }); addCommonConfiguration(yargs); @@ -979,6 +997,14 @@ export function createCommand(): cli.ICommand { if (isDefined(ttlOption)) { accessKeyAddCmd.ttl = parseDurationMilliseconds(ttlOption); } + const scopeOption: string = argv["scope"] as any; + if (isDefined(scopeOption)) { + accessKeyAddCmd.scopes = [scopeOption]; + } + const appOption: any = argv["app"]; + if (isDefined(appOption)) { + accessKeyAddCmd.appNames = (Array.isArray(appOption) ? appOption : [appOption]).map(String); + } } break; diff --git a/script/management-sdk.ts b/script/management-sdk.ts index 375e71d..851542b 100644 --- a/script/management-sdk.ts +++ b/script/management-sdk.ts @@ -117,7 +117,7 @@ class AccountManager { }); } - public addAccessKey(friendlyName: string, ttl?: number): Promise { + public addAccessKey(friendlyName: string, ttl?: number, scopes?: string[], appIds?: string[]): Promise { if (!friendlyName) { throw new Error("A name must be specified when adding an access key."); } @@ -127,6 +127,9 @@ class AccountManager { friendlyName, ttl, }; + // Absent means full access to all apps. + if (scopes && scopes.length) accessKeyRequest.scopes = scopes; + if (appIds && appIds.length) accessKeyRequest.appIds = appIds; return this.post(urlEncode(["/accessKeys/"]), JSON.stringify(accessKeyRequest), /*expectResponseBody=*/ true).then( (response: JsonResponse) => { @@ -160,6 +163,8 @@ class AccountManager { createdTime: serverAccessKey.createdTime, expires: serverAccessKey.expires, name: serverAccessKey.friendlyName, + scopes: serverAccessKey.scopes, + appNames: serverAccessKey.appNames, }); }); diff --git a/script/types.ts b/script/types.ts index 0ad79e0..81a6cb0 100644 --- a/script/types.ts +++ b/script/types.ts @@ -25,6 +25,8 @@ export interface AccessKey { expires: number; name: string; key?: string; + scopes?: string[]; + appNames?: string[] | null; } export interface Session { diff --git a/script/types/cli.ts b/script/types/cli.ts index 8be92af..0c59561 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -47,6 +47,8 @@ export interface ICommand { export interface IAccessKeyAddCommand extends ICommand { name: string; ttl?: number; + scopes?: string[]; + appNames?: string[]; } export interface IAccessKeyPatchCommand extends ICommand { diff --git a/script/types/rest-definitions.ts b/script/types/rest-definitions.ts index 47d95c9..b2d128b 100644 --- a/script/types/rest-definitions.ts +++ b/script/types/rest-definitions.ts @@ -13,11 +13,16 @@ export interface AccessKey extends AccessKeyBase { /*generated*/ createdTime?: number; expires: number; /*generated*/ isSession?: boolean; + /*generated*/ scopes?: string[]; + /*generated*/ appIds?: string[] | null; + /*generated*/ appNames?: string[] | null; } /*in*/ export interface AccessKeyRequest extends AccessKeyBase { ttl?: number; + scopes?: string[]; + appIds?: string[]; } /*out*/ @@ -109,6 +114,7 @@ export interface CollaboratorMap { /*inout*/ export interface App { + /*generated*/ id?: string; /*generated*/ collaborators?: CollaboratorMap; /*key*/ name: string; /*generated*/ deployments?: string[]; diff --git a/test/cli.ts b/test/cli.ts index 86006c3..a881fd0 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -63,7 +63,7 @@ export class SdkStub { }); } - public addAccessKey(name: string, ttl: number): Q.Promise { + public addAccessKey(name: string, ttl?: number, scopes?: string[], appIds?: string[]): Q.Promise { return Q({ key: "key123", createdTime: new Date().getTime(), @@ -123,6 +123,7 @@ export class SdkStub { public getApps(): Q.Promise { return Q([ { + id: "app-a-id", name: "a", collaborators: { "a@a.com": { permission: "Owner", isCurrentAccount: true }, @@ -130,6 +131,7 @@ export class SdkStub { deployments: ["Production", "Staging"], }, { + id: "app-b-id", name: "b", collaborators: { "a@a.com": { permission: "Owner", isCurrentAccount: true }, @@ -370,6 +372,44 @@ describe("CLI", () => { }); }); + it("accessKeyAdd resolves app names to ids and sends the requested scope", (done: Mocha.Done): void => { + var command: cli.IAccessKeyAddCommand = { + type: cli.CommandType.accessKeyAdd, + name: "CI key", + scopes: ["read"], + appNames: ["a"], + }; + + var addAccessKey: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addAccessKey"); + + cmdexec.execute(command).done((): void => { + sinon.assert.calledOnce(addAccessKey); + sinon.assert.calledWithExactly(addAccessKey, "CI key", undefined, ["read"], ["app-a-id"]); + sinon.assert.calledThrice(log); + assert.equal(log.args[1][0], "Scope: read only, a"); + done(); + }); + }); + + it("accessKeyAdd does not create a key when an app name is not found", (done: Mocha.Done): void => { + var command: cli.IAccessKeyAddCommand = { + type: cli.CommandType.accessKeyAdd, + name: "CI key", + appNames: ["does-not-exist"], + }; + + var addAccessKey: sinon.SinonSpy = sandbox.spy(cmdexec.sdk, "addAccessKey"); + + cmdexec.execute(command).then( + (): void => done(new Error("Should have rejected")), + (error: any): void => { + assert.ok(/was not found/.test(error.message)); + sinon.assert.notCalled(addAccessKey); + done(); + } + ); + }); + it("accessKeyPatch updates access key with new name", (done: Mocha.Done): void => { var command: cli.IAccessKeyPatchCommand = { type: cli.CommandType.accessKeyPatch, @@ -454,6 +494,20 @@ describe("CLI", () => { }); }); + it("accessKeyList shows what each key can reach", (done: Mocha.Done): void => { + var command: cli.IAccessKeyListCommand = { + type: cli.CommandType.accessKeyList, + format: "table", + }; + + cmdexec.execute(command).done((): void => { + var table: string = log.args[0][0]; + assert.ok(/Scope/.test(table), table); + assert.ok(/full access, all apps/.test(table), table); + done(); + }); + }); + it("accessKeyRemove removes access key", (done: Mocha.Done): void => { var command: cli.IAccessKeyRemoveCommand = { type: cli.CommandType.accessKeyRemove, @@ -524,6 +578,7 @@ describe("CLI", () => { var actual: string = log.args[0][0]; var expected = [ { + id: "app-a-id", name: "a", collaborators: { "a@a.com": { @@ -534,6 +589,7 @@ describe("CLI", () => { deployments: ["Production", "Staging"], }, { + id: "app-b-id", name: "b", collaborators: { "a@a.com": { diff --git a/test/management-sdk.ts b/test/management-sdk.ts index 452fe0a..0af3c5b 100644 --- a/test/management-sdk.ts +++ b/test/management-sdk.ts @@ -404,6 +404,26 @@ describe("Management SDK", () => { ); }); + it("addAccessKey sends scopes and appIds when they are set", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ accessKey: { name: "k", friendlyName: "CI key", createdTime: 0, expires: 1 } }), 201); + manager.addAccessKey("CI key", undefined, ["read"], ["app-a-id"]).done(() => { + const body = typeof lastRequestBody === "string" ? JSON.parse(lastRequestBody) : lastRequestBody; + assert.deepStrictEqual(body.scopes, ["read"]); + assert.deepStrictEqual(body.appIds, ["app-a-id"]); + done(); + }, rejectHandler); + }); + + it("addAccessKey leaves scopes and appIds out when they are not set", (done: Mocha.Done) => { + mockReturn(JSON.stringify({ accessKey: { name: "k", friendlyName: "CI key", createdTime: 0, expires: 1 } }), 201); + manager.addAccessKey("CI key").done(() => { + const body = typeof lastRequestBody === "string" ? JSON.parse(lastRequestBody) : lastRequestBody; + assert.ok(!("scopes" in body), "an unscoped key must not pin itself to full"); + assert.ok(!("appIds" in body), "an unscoped key must not pin itself to a list of apps"); + done(); + }, rejectHandler); + }); + it("getAutoRollbackConfig reads autoRollbackConfig, the key the API actually sends", (done: Mocha.Done) => { mockReturn(JSON.stringify({ autoRollbackConfig: { enabled: true, threshold: 25 } }), 200); manager.getAutoRollbackConfig("appName", "Staging").done((config: any) => { @@ -421,11 +441,15 @@ function rejectHandler(val: any): void { } // Wrapper for superagent-mock that abstracts away information not needed for SDK tests +let lastRequestBody: any; + function mockReturn(bodyText: string, statusCode: number, header = {}): void { + lastRequestBody = undefined; require("superagent-mock")(request, [ { pattern: "https://api.deploypulse.io/(.*)", fixtures: function (match: any, params: any): any { + lastRequestBody = params; var isOk = statusCode >= 200 && statusCode < 300; if (!isOk) { var err: any = new Error(bodyText);