Skip to content
Draft
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
14 changes: 14 additions & 0 deletions src/crashlytics/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { FirebaseError } from "../error";
describe("onboarding", () => {
let ensureStub: sinon.SinonStub;
let bucketStub: sinon.SinonStub;
let grantViewAccessStub: sinon.SinonStub;
let sinkStub: sinon.SinonStub;
let configStub: sinon.SinonStub;
let checkBillingStub: sinon.SinonStub;
Expand All @@ -31,6 +32,11 @@ describe("onboarding", () => {
name: "projects/test-project/locations/global/buckets/firebase-telemetry",
analyticsEnabled: true,
});
grantViewAccessStub = sinon.stub(cloudlogging, "grantLogViewAccess").resolves({
bindings: [{ role: "roles/logging.viewAccessor", members: ["projectViewer:test-project"] }],
etag: "etag",
version: 3,
});
sinkStub = sinon.stub(cloudlogging, "createOrUpdateLogSink").resolves({
name: "firebase-telemetry-routing",
destination: "dest",
Expand Down Expand Up @@ -60,6 +66,14 @@ describe("onboarding", () => {
"global",
true,
);
expect(grantViewAccessStub).to.have.been.calledWith(
"test-project",
"firebase-telemetry",
"_AllLogs",
["projectViewer:test-project", "projectEditor:test-project"],
"roles/logging.viewAccessor",
"global",
);
expect(sinkStub).to.have.been.calledOnce;
expect(configStub).to.have.been.calledWith(
"test-project",
Expand Down
20 changes: 19 additions & 1 deletion src/crashlytics/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { checkBillingEnabled, enableBilling } from "../gcp/cloudbilling";
import {
createOrUpdateLogBucket,
createOrUpdateLogSink,
grantLogViewAccess,
LogBucket,
LogSink,
} from "../gcp/cloudlogging";
Expand All @@ -13,6 +14,8 @@ import { logLabeledBullet, logLabeledSuccess } from "../utils";
export const CRASHLYTICS_TELEMETRY_BUCKET_ID = "firebase-telemetry";
export const CRASHLYTICS_TELEMETRY_SINK_ID = "firebase-telemetry-routing";
export const CRASHLYTICS_TELEMETRY_RESOURCE_TYPE = "firebasetelemetry.googleapis.com/App";
export const CRASHLYTICS_TELEMETRY_VIEW_ID = "_AllLogs";
export const CRASHLYTICS_TELEMETRY_VIEW_ACCESSOR_ROLE = "roles/logging.viewAccessor";

export interface OnboardWebResult {
bucket: LogBucket;
Expand All @@ -22,7 +25,8 @@ export interface OnboardWebResult {

/**
* Onboards a Firebase Web App to Crashlytics by enabling required APIs,
* setting up Cloud Logging bucket and sink routing, and creating a Telemetry Config.
* setting up Cloud Logging bucket and sink routing, granting view accessor permissions,
* and creating a Telemetry Config.
*/
export async function onboardCrashlyticsWeb(
projectId: string,
Expand Down Expand Up @@ -58,6 +62,20 @@ export async function onboardCrashlyticsWeb(
);
logLabeledSuccess("crashlytics", "Cloud Logging bucket configured.");

logLabeledBullet(
"crashlytics",
`Granting view accessor permission on bucket '${CRASHLYTICS_TELEMETRY_BUCKET_ID}' to project viewers and editors...`,
);
await grantLogViewAccess(
projectId,
CRASHLYTICS_TELEMETRY_BUCKET_ID,
CRASHLYTICS_TELEMETRY_VIEW_ID,
[`projectViewer:${projectId}`, `projectEditor:${projectId}`],
CRASHLYTICS_TELEMETRY_VIEW_ACCESSOR_ROLE,
"global",
);
logLabeledSuccess("crashlytics", "Cloud Logging view permissions configured.");

const destination = `logging.googleapis.com/projects/${projectId}/locations/global/buckets/${CRASHLYTICS_TELEMETRY_BUCKET_ID}`;
const filter = `resource.type="${CRASHLYTICS_TELEMETRY_RESOURCE_TYPE}"`;
logLabeledBullet(
Expand Down
261 changes: 261 additions & 0 deletions src/gcp/cloudlogging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
const entries = [{ logName: "log1" }];
nock(cloudloggingOrigin())
.post("/v2/entries:list", (body) => {
expect(body.pageToken).to.equal("token");

Check warning on line 44 in src/gcp/cloudlogging.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe member access .pageToken on an `any` value
return true;
})
.reply(200, { entries, nextPageToken: "next" });
Expand Down Expand Up @@ -167,4 +167,265 @@
).to.eventually.deep.equal(sink);
});
});

describe("getLogViewIamPolicy", () => {
it("should resolve with policy on success", async () => {
const policy = {
bindings: [{ role: "roles/logging.viewAccessor", members: ["projectViewer:project"] }],
etag: "etag123",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:getIamPolicy",
)
.reply(200, policy);

await expect(
cloudlogging.getLogViewIamPolicy("project", "firebase-telemetry", "_AllLogs", "global"),
).to.eventually.deep.equal(policy);
});

it("should reject if API call fails", async () => {
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:getIamPolicy",
)
.reply(403, { error: "forbidden" });

const promise = cloudlogging.getLogViewIamPolicy(
"project",
"firebase-telemetry",
"_AllLogs",
"global",
);
await expect(promise).to.be.rejectedWith(
FirebaseError,
"Failed to get IAM policy for log view _AllLogs on bucket firebase-telemetry (status 403):",
);
await expect(promise).to.be.rejected.and.eventually.have.property("status", 403);
});
});

describe("setLogViewIamPolicy", () => {
it("should resolve with policy on success", async () => {
const policy = {
bindings: [{ role: "roles/logging.viewAccessor", members: ["projectViewer:project"] }],
etag: "etag123",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:setIamPolicy",
{ policy },
)
.reply(200, policy);

await expect(
cloudlogging.setLogViewIamPolicy(
"project",
"firebase-telemetry",
"_AllLogs",
policy,
"global",
),
).to.eventually.deep.equal(policy);
});

it("should reject if API call fails", async () => {
const policy = {
bindings: [],
etag: "etag123",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:setIamPolicy",
{ policy },
)
.reply(500, { error: "internal error" });

const promise = cloudlogging.setLogViewIamPolicy(
"project",
"firebase-telemetry",
"_AllLogs",
policy,
"global",
);
await expect(promise).to.be.rejectedWith(
FirebaseError,
"Failed to set IAM policy for log view _AllLogs on bucket firebase-telemetry (status 500):",
);
await expect(promise).to.be.rejected.and.eventually.have.property("status", 500);
});
});

describe("grantLogViewAccess", () => {
it("should add binding when role does not exist in policy", async () => {
const existingPolicy = {
bindings: [{ role: "roles/logging.viewer", members: ["user:admin@example.com"] }],
etag: "etag123",
version: 3,
};
const updatedPolicy = {
bindings: [
{ role: "roles/logging.viewer", members: ["user:admin@example.com"] },
{ role: "roles/logging.viewAccessor", members: ["projectViewer:project"] },
],
etag: "etag123",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:getIamPolicy",
)
.reply(200, existingPolicy);
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:setIamPolicy",
{ policy: updatedPolicy },
)
.reply(200, updatedPolicy);

const result = await cloudlogging.grantLogViewAccess(
"project",
"firebase-telemetry",
"_AllLogs",
"projectViewer:project",
"roles/logging.viewAccessor",
"global",
);
expect(result).to.deep.equal(updatedPolicy);
});

it("should add member to existing role binding", async () => {
const existingPolicy = {
bindings: [{ role: "roles/logging.viewAccessor", members: ["user:existing@example.com"] }],
etag: "etag123",
version: 3,
};
const updatedPolicy = {
bindings: [
{
role: "roles/logging.viewAccessor",
members: ["user:existing@example.com", "projectViewer:project"],
},
],
etag: "etag123",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:getIamPolicy",
)
.reply(200, existingPolicy);
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:setIamPolicy",
{ policy: updatedPolicy },
)
.reply(200, updatedPolicy);

const result = await cloudlogging.grantLogViewAccess(
"project",
"firebase-telemetry",
"_AllLogs",
"projectViewer:project",
"roles/logging.viewAccessor",
"global",
);
expect(result).to.deep.equal(updatedPolicy);
});

it("should return existing policy without calling setIamPolicy if member already has role", async () => {
const existingPolicy = {
bindings: [{ role: "roles/logging.viewAccessor", members: ["projectViewer:project"] }],
etag: "etag123",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:getIamPolicy",
)
.reply(200, existingPolicy);

const result = await cloudlogging.grantLogViewAccess(
"project",
"firebase-telemetry",
"_AllLogs",
"projectViewer:project",
"roles/logging.viewAccessor",
"global",
);
expect(result).to.deep.equal(existingPolicy);
});

it("should create new policy if getLogViewIamPolicy returns 404", async () => {
const expectedPolicy = {
bindings: [{ role: "roles/logging.viewAccessor", members: ["projectViewer:project"] }],
etag: "",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:getIamPolicy",
)
.reply(404, { error: "not found" });
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:setIamPolicy",
{ policy: expectedPolicy },
)
.reply(200, expectedPolicy);

const result = await cloudlogging.grantLogViewAccess(
"project",
"firebase-telemetry",
"_AllLogs",
"projectViewer:project",
"roles/logging.viewAccessor",
"global",
);
expect(result).to.deep.equal(expectedPolicy);
});

it("should add multiple members to the role binding", async () => {
const existingPolicy = {
bindings: [],
etag: "etag123",
version: 3,
};
const updatedPolicy = {
bindings: [
{
role: "roles/logging.viewAccessor",
members: ["projectViewer:project", "projectEditor:project", "projectOwner:project"],
},
],
etag: "etag123",
version: 3,
};
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:getIamPolicy",
)
.reply(200, existingPolicy);
nock(cloudloggingOrigin())
.post(
"/v2/projects/project/locations/global/buckets/firebase-telemetry/views/_AllLogs:setIamPolicy",
{ policy: updatedPolicy },
)
.reply(200, updatedPolicy);

const result = await cloudlogging.grantLogViewAccess(
"project",
"firebase-telemetry",
"_AllLogs",
["projectViewer:project", "projectEditor:project", "projectOwner:project"],
"roles/logging.viewAccessor",
"global",
);
expect(result).to.deep.equal(updatedPolicy);
});
});
});
Loading
Loading