Skip to content

Commit 9018326

Browse files
authored
Can grant access to emails (user, group, SA) (#8357)
* Can grant access to emails (user, group, SA) * Support multiple secrets * Lint and changelog * PR feedback
1 parent 31d343a commit 9018326

File tree

4 files changed

+223
-12
lines changed

4 files changed

+223
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
- Fix bug in Auth emulator where accounts:lookup is case-sensitive for emails (#8344)
2+
- firebase apphosting:secrets:grantAccess can now grant access to emails and can grant multiple secrets at once (#8357)

src/apphosting/secrets/index.spec.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as utilsImport from "../../utils";
1111
import * as promptImport from "../../prompt";
1212

1313
import { Secret } from "../yaml";
14+
import { FirebaseError } from "../../error";
1415

1516
describe("secrets", () => {
1617
let gcsm: sinon.SinonStubbedInstance<typeof gcsmImport>;
@@ -259,6 +260,128 @@ describe("secrets", () => {
259260
});
260261
});
261262

263+
describe("grantEmailsSecretAccess", () => {
264+
const secret = {
265+
projectId: "projectId",
266+
name: "secret",
267+
};
268+
const secret2 = {
269+
projectId: "projectId",
270+
name: "secret2",
271+
};
272+
const existingPolicy: iam.Policy = {
273+
version: 1,
274+
etag: "tag",
275+
bindings: [
276+
{
277+
role: "roles/viewer",
278+
members: ["serviceAccount:existingSA"],
279+
},
280+
],
281+
};
282+
283+
it("should brute force its way to success", async () => {
284+
gcsm.getIamPolicy.resolves(existingPolicy);
285+
gcsm.setIamPolicy
286+
.onFirstCall()
287+
.rejects(
288+
new FirebaseError(
289+
'Principal [email protected] is of type "group". The principal should appear as "group:[email protected]"',
290+
),
291+
);
292+
gcsm.setIamPolicy.onSecondCall().resolves();
293+
294+
await secrets.grantEmailsSecretAccess(
295+
secret.projectId,
296+
[secret.name],
297+
298+
);
299+
300+
expect(gcsm.getIamPolicy).to.be.calledWithMatch(secret);
301+
expect(gcsm.setIamPolicy.firstCall).to.be.calledWithMatch(secret, [
302+
{
303+
role: "roles/viewer",
304+
members: ["serviceAccount:existingSA"],
305+
},
306+
{
307+
role: "roles/secretmanager.secretAccessor",
308+
members: ["user:[email protected]", "user:[email protected]"],
309+
},
310+
]);
311+
expect(gcsm.setIamPolicy.secondCall).to.be.calledWithMatch(secret, [
312+
{
313+
role: "roles/viewer",
314+
members: ["serviceAccount:existingSA"],
315+
},
316+
{
317+
role: "roles/secretmanager.secretAccessor",
318+
members: ["user:[email protected]", "group:[email protected]"],
319+
},
320+
]);
321+
});
322+
323+
it("Should remember what it learns while brute forcing across multiple secrets", async () => {
324+
gcsm.getIamPolicy.resolves(existingPolicy);
325+
gcsm.setIamPolicy
326+
.onFirstCall()
327+
.rejects(
328+
new FirebaseError(
329+
'Principal [email protected] is of type "group". The principal should appear as "group:[email protected]"',
330+
),
331+
);
332+
gcsm.setIamPolicy.onSecondCall().resolves();
333+
gcsm.setIamPolicy.onThirdCall().resolves();
334+
335+
await secrets.grantEmailsSecretAccess(
336+
secret.projectId,
337+
[secret.name, secret2.name],
338+
339+
);
340+
341+
expect(gcsm.getIamPolicy).to.be.calledWithMatch(secret);
342+
expect(gcsm.setIamPolicy).to.be.calledThrice;
343+
expect(gcsm.setIamPolicy.firstCall).to.be.calledWithMatch(secret, [
344+
{
345+
role: "roles/viewer",
346+
members: ["serviceAccount:existingSA"],
347+
},
348+
{
349+
role: "roles/secretmanager.secretAccessor",
350+
members: ["user:[email protected]", "user:[email protected]"],
351+
},
352+
]);
353+
expect(gcsm.setIamPolicy.secondCall).to.be.calledWithMatch(secret, [
354+
{
355+
role: "roles/viewer",
356+
members: ["serviceAccount:existingSA"],
357+
},
358+
{
359+
role: "roles/secretmanager.secretAccessor",
360+
members: ["user:[email protected]", "group:[email protected]"],
361+
},
362+
]);
363+
expect(gcsm.setIamPolicy.thirdCall).to.be.calledWithMatch(secret2, [
364+
{
365+
role: "roles/viewer",
366+
members: ["serviceAccount:existingSA"],
367+
},
368+
{
369+
role: "roles/secretmanager.secretAccessor",
370+
members: ["user:[email protected]", "group:[email protected]"],
371+
},
372+
]);
373+
});
374+
375+
it("Should fail if the error is not specifically a principal type error", async () => {
376+
gcsm.getIamPolicy.resolves(existingPolicy);
377+
gcsm.setIamPolicy.rejects(new FirebaseError("Some other error"));
378+
379+
await expect(
380+
secrets.grantEmailsSecretAccess(secret.projectId, [secret.name], ["[email protected]"]),
381+
).to.eventually.be.rejectedWith(/Failed to set IAM bindings/);
382+
});
383+
});
384+
262385
describe("fetchSecrets", () => {
263386
const projectId = "randomProject";
264387
it("correctly attempts to fetch secret and it's version", async () => {

src/apphosting/secrets/index.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,84 @@ export async function grantSecretAccess(
9898
);
9999
}
100100

101+
const updatedBindings = existingBindings.concat(newBindings);
101102
try {
102-
// TODO: Merge with existing bindings with the same role
103-
const updatedBindings = existingBindings.concat(newBindings);
104103
await gcsm.setIamPolicy({ projectId, name: secretName }, updatedBindings);
105104
} catch (err: unknown) {
106105
throw new FirebaseError(
107-
`Failed to set IAM bindings ${JSON.stringify(newBindings)} on secret: ${secretName}. Ensure you have the permissions to do so and try again.`,
106+
`Failed to set IAM bindings ${JSON.stringify(newBindings)} on secret: ${secretName}. Ensure you have the permissions to do so and try again. ` +
107+
"For more information visit https://cloud.google.com/secret-manager/docs/manage-access-to-secrets#required-roles",
108108
{ original: getError(err) },
109109
);
110110
}
111111

112112
utils.logSuccess(`Successfully set IAM bindings on secret ${secretName}.\n`);
113113
}
114114

115+
/**
116+
* Grants the following users or groups access to the provided secret.
117+
*/
118+
export async function grantEmailsSecretAccess(
119+
projectId: string,
120+
secretNames: string[],
121+
emails: string[],
122+
): Promise<void> {
123+
// This feels like a hack, but it's actually sorta taking advantage of an escalation of privilege in Google IAM.
124+
// The correct way to determine if an email address is a user or group is to use the Google Admin API
125+
// (GET e.g. admin.googleapis.com/admin/directory/v1/users/<email> or GET admin.googleapis.com/admin/driectory/v1/groups/<email>)
126+
// but that would require us to have admin permissions on GMail for example. Fortunately, IAM seems to give us well formed errors
127+
// that dictate what type of role the email address should have been bound with. This seems... like a design mistake. If they knew
128+
// already, why not just accept the value without leaking its type?
129+
// Note: we keep typeGuesses outside of the loop so that we learn the type of principal an email is once across all secrets.
130+
const typeGuesses = Object.fromEntries(emails.map((email) => [email, "user"]));
131+
for (const secretName of secretNames) {
132+
let existingBindings;
133+
try {
134+
existingBindings = (await gcsm.getIamPolicy({ projectId, name: secretName })).bindings || [];
135+
} catch (err: unknown) {
136+
throw new FirebaseError(
137+
`Failed to get IAM bindings on secret: ${secretName}. Ensure you have the permissions to do so and try again. ` +
138+
"For more information visit https://cloud.google.com/secret-manager/docs/manage-access-to-secrets#required-roles",
139+
{ original: getError(err) },
140+
);
141+
}
142+
143+
do {
144+
try {
145+
const newBindings: iam.Binding[] = [
146+
{
147+
role: "roles/secretmanager.secretAccessor",
148+
members: Object.entries(typeGuesses).map(([email, type]) => `${type}:${email}`),
149+
},
150+
];
151+
const updatedBindings = existingBindings.concat(newBindings);
152+
await gcsm.setIamPolicy({ projectId, name: secretName }, updatedBindings);
153+
break;
154+
} catch (err: any) {
155+
if (!(err instanceof FirebaseError)) {
156+
throw new FirebaseError(
157+
`Unexpected error updating IAM bindings on secret: ${secretName}`,
158+
{
159+
original: getError(err),
160+
},
161+
);
162+
}
163+
const match = /Principal (.*) is of type "([^"]+)"/.exec(err.message);
164+
if (!match) {
165+
throw new FirebaseError(
166+
`Failed to set IAM bindings on secret: ${secretName}. Ensure you have the permissions to do so and try again.`,
167+
{ original: getError(err) },
168+
);
169+
}
170+
typeGuesses[match[1]] = match[2];
171+
continue;
172+
}
173+
} while (true);
174+
175+
utils.logSuccess(`Successfully set IAM bindings on secret ${secretName}.\n`);
176+
}
177+
}
178+
115179
/**
116180
* Ensures a secret exists for use with app hosting, optionally locked to a region.
117181
* If a secret exists, we verify the user is not trying to change the region and verifies a secret

src/commands/apphosting-secrets-grantaccess.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import * as apphosting from "../gcp/apphosting";
99
import * as secrets from "../apphosting/secrets";
1010
import { getBackendForAmbiguousLocation } from "../apphosting/backend";
1111

12-
export const command = new Command("apphosting:secrets:grantaccess <secretName>")
13-
.description("grant service accounts permissions to the provided secret")
12+
export const command = new Command("apphosting:secrets:grantaccess <secretNames>")
13+
.description(
14+
"grant service accounts, users, or groups permissions to the provided secret(s). Can pass one or more secrets, separated by a comma",
15+
)
1416
.option("-l, --location <location>", "backend location", "-")
1517
.option("-b, --backend <backend>", "backend name")
18+
.option("-e, --emails <emails>", "comma delimited list of user or group emails")
1619
.before(requireAuth)
1720
.before(secretManager.ensureApi)
1821
.before(apphosting.ensureApiEnabled)
@@ -24,19 +27,35 @@ export const command = new Command("apphosting:secrets:grantaccess <secretName>"
2427
"secretmanager.secrets.getIamPolicy",
2528
"secretmanager.secrets.setIamPolicy",
2629
])
27-
.action(async (secretName: string, options: Options) => {
30+
.action(async (secretNames: string, options: Options) => {
2831
const projectId = needProjectId(options);
2932
const projectNumber = await needProjectNumber(options);
3033

31-
if (!options.backend) {
34+
if (!options.backend && !options.emails) {
3235
throw new FirebaseError(
33-
"Missing required flag --backend. See firebase apphosting:secrets:grantaccess --help for more info",
36+
"Missing required flag --backend or --emails. See firebase apphosting:secrets:grantaccess --help for more info",
37+
);
38+
}
39+
if (options.backend && options.emails) {
40+
throw new FirebaseError(
41+
"Cannot specify both --backend and --emails. See firebase apphosting:secrets:grantaccess --help for more info",
3442
);
3543
}
3644

37-
const exists = await secretManager.secretExists(projectId, secretName);
38-
if (!exists) {
39-
throw new FirebaseError(`Cannot find secret ${secretName}`);
45+
const secretList = secretNames.split(",");
46+
for (const secretName of secretList) {
47+
const exists = await secretManager.secretExists(projectId, secretName);
48+
if (!exists) {
49+
throw new FirebaseError(`Cannot find secret ${secretName}`);
50+
}
51+
}
52+
53+
if (options.emails) {
54+
return await secrets.grantEmailsSecretAccess(
55+
projectId,
56+
secretList,
57+
String(options.emails).split(","),
58+
);
4059
}
4160

4261
const backendId = options.backend as string;
@@ -54,5 +73,9 @@ export const command = new Command("apphosting:secrets:grantaccess <secretName>"
5473

5574
const accounts = secrets.toMulti(secrets.serviceAccountsForBackend(projectNumber, backend));
5675

57-
await secrets.grantSecretAccess(projectId, projectNumber, secretName, accounts);
76+
await Promise.allSettled(
77+
secretList.map((secretName) =>
78+
secrets.grantSecretAccess(projectId, projectNumber, secretName, accounts),
79+
),
80+
);
5881
});

0 commit comments

Comments
 (0)