Skip to content

Commit 9373d39

Browse files
authored
Using async project ID discovery API in SecurityRules (#732)
1 parent 00892e0 commit 9373d39

File tree

4 files changed

+149
-83
lines changed

4 files changed

+149
-83
lines changed

src/security-rules/security-rules-api-client.ts

Lines changed: 87 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { HttpRequestConfig, HttpClient, HttpError } from '../utils/api-request';
17+
import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request';
1818
import { PrefixedFirebaseError } from '../utils/error';
1919
import { FirebaseSecurityRulesError, SecurityRulesErrorCode } from './security-rules-utils';
20+
import * as utils from '../utils/index';
2021
import * as validator from '../utils/validator';
22+
import { FirebaseApp } from '../firebase-app';
2123

2224
const RULES_V1_API = 'https://firebaserules.googleapis.com/v1';
2325
const FIREBASE_VERSION_HEADER = {
@@ -54,25 +56,18 @@ export interface ListRulesetsResponse {
5456
*/
5557
export class SecurityRulesApiClient {
5658

57-
private readonly projectIdPrefix: string;
58-
private readonly url: string;
59+
private readonly httpClient: HttpClient;
60+
private projectIdPrefix?: string;
5961

60-
constructor(private readonly httpClient: HttpClient, projectId: string | null) {
61-
if (!validator.isNonNullObject(httpClient)) {
62-
throw new FirebaseSecurityRulesError(
63-
'invalid-argument', 'HttpClient must be a non-null object.');
64-
}
65-
66-
if (!validator.isNonEmptyString(projectId)) {
62+
constructor(private readonly app: FirebaseApp) {
63+
if (!validator.isNonNullObject(app) || !('options' in app)) {
6764
throw new FirebaseSecurityRulesError(
6865
'invalid-argument',
69-
'Failed to determine project ID. Initialize the SDK with service account credentials, or '
70-
+ 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT '
71-
+ 'environment variable.');
66+
'First argument passed to admin.securityRules() must be a valid Firebase app '
67+
+ 'instance.');
7268
}
7369

74-
this.projectIdPrefix = `projects/${projectId}`;
75-
this.url = `${RULES_V1_API}/${this.projectIdPrefix}`;
70+
this.httpClient = new AuthorizedHttpClient(app);
7671
}
7772

7873
public getRuleset(name: string): Promise<RulesetResponse> {
@@ -105,23 +100,24 @@ export class SecurityRulesApiClient {
105100
}
106101
}
107102

108-
const request: HttpRequestConfig = {
109-
method: 'POST',
110-
url: `${this.url}/rulesets`,
111-
data: ruleset,
112-
};
113-
return this.sendRequest<RulesetResponse>(request);
103+
return this.getUrl()
104+
.then((url) => {
105+
const request: HttpRequestConfig = {
106+
method: 'POST',
107+
url: `${url}/rulesets`,
108+
data: ruleset,
109+
};
110+
return this.sendRequest<RulesetResponse>(request);
111+
});
114112
}
115113

116114
public deleteRuleset(name: string): Promise<void> {
117-
return Promise.resolve()
118-
.then(() => {
119-
return this.getRulesetName(name);
120-
})
121-
.then((rulesetName) => {
115+
return this.getUrl()
116+
.then((url) => {
117+
const rulesetName = this.getRulesetName(name);
122118
const request: HttpRequestConfig = {
123119
method: 'DELETE',
124-
url: `${this.url}/${rulesetName}`,
120+
url: `${url}/${rulesetName}`,
125121
};
126122
return this.sendRequest<void>(request);
127123
});
@@ -151,28 +147,61 @@ export class SecurityRulesApiClient {
151147
delete data.pageToken;
152148
}
153149

154-
const request: HttpRequestConfig = {
155-
method: 'GET',
156-
url: `${this.url}/rulesets`,
157-
data,
158-
};
159-
return this.sendRequest<ListRulesetsResponse>(request);
150+
return this.getUrl()
151+
.then((url) => {
152+
const request: HttpRequestConfig = {
153+
method: 'GET',
154+
url: `${url}/rulesets`,
155+
data,
156+
};
157+
return this.sendRequest<ListRulesetsResponse>(request);
158+
});
160159
}
161160

162161
public getRelease(name: string): Promise<Release> {
163162
return this.getResource<Release>(`releases/${name}`);
164163
}
165164

166165
public updateRelease(name: string, rulesetName: string): Promise<Release> {
167-
const data = {
168-
release: this.getReleaseDescription(name, rulesetName),
169-
};
170-
const request: HttpRequestConfig = {
171-
method: 'PATCH',
172-
url: `${this.url}/releases/${name}`,
173-
data,
174-
};
175-
return this.sendRequest<Release>(request);
166+
return this.getUrl()
167+
.then((url) => {
168+
return this.getReleaseDescription(name, rulesetName)
169+
.then((release) => {
170+
const request: HttpRequestConfig = {
171+
method: 'PATCH',
172+
url: `${url}/releases/${name}`,
173+
data: {release},
174+
};
175+
return this.sendRequest<Release>(request);
176+
});
177+
});
178+
}
179+
180+
private getUrl(): Promise<string> {
181+
return this.getProjectIdPrefix()
182+
.then((projectIdPrefix) => {
183+
return `${RULES_V1_API}/${projectIdPrefix}`;
184+
});
185+
}
186+
187+
private getProjectIdPrefix(): Promise<string> {
188+
if (this.projectIdPrefix) {
189+
return Promise.resolve(this.projectIdPrefix);
190+
}
191+
192+
return utils.findProjectId(this.app)
193+
.then((projectId) => {
194+
if (!validator.isNonEmptyString(projectId)) {
195+
throw new FirebaseSecurityRulesError(
196+
'invalid-argument',
197+
'Failed to determine project ID. Initialize the SDK with service account credentials, or '
198+
+ 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT '
199+
+ 'environment variable.');
200+
}
201+
202+
this.projectIdPrefix = `projects/${projectId}`;
203+
return this.projectIdPrefix;
204+
});
176205
}
177206

178207
/**
@@ -183,18 +212,24 @@ export class SecurityRulesApiClient {
183212
* @returns {Promise<T>} A promise that fulfills with the resource.
184213
*/
185214
private getResource<T>(name: string): Promise<T> {
186-
const request: HttpRequestConfig = {
187-
method: 'GET',
188-
url: `${this.url}/${name}`,
189-
};
190-
return this.sendRequest<T>(request);
215+
return this.getUrl()
216+
.then((url) => {
217+
const request: HttpRequestConfig = {
218+
method: 'GET',
219+
url: `${url}/${name}`,
220+
};
221+
return this.sendRequest<T>(request);
222+
});
191223
}
192224

193-
private getReleaseDescription(name: string, rulesetName: string): Release {
194-
return {
195-
name: `${this.projectIdPrefix}/releases/${name}`,
196-
rulesetName: `${this.projectIdPrefix}/${this.getRulesetName(rulesetName)}`,
197-
};
225+
private getReleaseDescription(name: string, rulesetName: string): Promise<Release> {
226+
return this.getProjectIdPrefix()
227+
.then((projectIdPrefix) => {
228+
return {
229+
name: `${projectIdPrefix}/releases/${name}`,
230+
rulesetName: `${projectIdPrefix}/${this.getRulesetName(rulesetName)}`,
231+
};
232+
});
198233
}
199234

200235
private getRulesetName(name: string): string {

src/security-rules/security-rules.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,10 @@
1616

1717
import { FirebaseServiceInterface, FirebaseServiceInternalsInterface } from '../firebase-service';
1818
import { FirebaseApp } from '../firebase-app';
19-
import * as utils from '../utils/index';
2019
import * as validator from '../utils/validator';
2120
import {
2221
SecurityRulesApiClient, RulesetResponse, RulesetContent, ListRulesetsResponse,
2322
} from './security-rules-api-client';
24-
import { AuthorizedHttpClient } from '../utils/api-request';
2523
import { FirebaseSecurityRulesError } from './security-rules-utils';
2624

2725
/**
@@ -115,15 +113,7 @@ export class SecurityRules implements FirebaseServiceInterface {
115113
* @constructor
116114
*/
117115
constructor(readonly app: FirebaseApp) {
118-
if (!validator.isNonNullObject(app) || !('options' in app)) {
119-
throw new FirebaseSecurityRulesError(
120-
'invalid-argument',
121-
'First argument passed to admin.securityRules() must be a valid Firebase app '
122-
+ 'instance.');
123-
}
124-
125-
const projectId = utils.getProjectId(app);
126-
this.client = new SecurityRulesApiClient(new AuthorizedHttpClient(app), projectId);
116+
this.client = new SecurityRulesApiClient(app);
127117
}
128118

129119
/**

test/unit/security-rules/security-rules-api-client.spec.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import { SecurityRulesApiClient, RulesetContent } from '../../../src/security-ru
2323
import { FirebaseSecurityRulesError } from '../../../src/security-rules/security-rules-utils';
2424
import { HttpClient } from '../../../src/utils/api-request';
2525
import * as utils from '../utils';
26+
import * as mocks from '../../resources/mocks';
2627
import { FirebaseAppError } from '../../../src/utils/error';
28+
import { FirebaseApp } from '../../../src/firebase-app';
2729

2830
const expect = chai.expect;
2931

@@ -39,35 +41,41 @@ describe('SecurityRulesApiClient', () => {
3941
},
4042
};
4143
const EXPECTED_HEADERS = {
44+
'Authorization': 'Bearer mock-token',
4245
'X-Firebase-Client': 'fire-admin-node/<XXX_SDK_VERSION_XXX>',
4346
};
47+
const noProjectId = 'Failed to determine project ID. Initialize the SDK with service '
48+
+ 'account credentials, or set project ID as an app option. Alternatively, set the '
49+
+ 'GOOGLE_CLOUD_PROJECT environment variable.';
4450

45-
const apiClient: SecurityRulesApiClient = new SecurityRulesApiClient(
46-
new HttpClient(), 'test-project');
51+
const mockOptions = {
52+
credential: new mocks.MockCredential(),
53+
projectId: 'test-project',
54+
};
55+
56+
const clientWithoutProjectId = new SecurityRulesApiClient(
57+
mocks.mockCredentialApp());
4758

4859
// Stubs used to simulate underlying api calls.
4960
let stubs: sinon.SinonStub[] = [];
61+
let app: FirebaseApp;
62+
let apiClient: SecurityRulesApiClient;
63+
64+
beforeEach(() => {
65+
app = mocks.appWithOptions(mockOptions);
66+
apiClient = new SecurityRulesApiClient(app);
67+
});
5068

5169
afterEach(() => {
5270
_.forEach(stubs, (stub) => stub.restore());
5371
stubs = [];
72+
return app.delete();
5473
});
5574

5675
describe('Constructor', () => {
57-
it('should throw when the HttpClient is null', () => {
58-
expect(() => new SecurityRulesApiClient(null as unknown as HttpClient, 'test'))
59-
.to.throw('HttpClient must be a non-null object.');
60-
});
61-
62-
const invalidProjectIds: any[] = [null, undefined, '', {}, [], true, 1];
63-
const noProjectId = 'Failed to determine project ID. Initialize the SDK with service '
64-
+ 'account credentials, or set project ID as an app option. Alternatively, set the '
65-
+ 'GOOGLE_CLOUD_PROJECT environment variable.';
66-
invalidProjectIds.forEach((invalidProjectId) => {
67-
it(`should throw when the projectId is: ${invalidProjectId}`, () => {
68-
expect(() => new SecurityRulesApiClient(new HttpClient(), invalidProjectId))
69-
.to.throw(noProjectId);
70-
});
76+
it('should throw when the app is null', () => {
77+
expect(() => new SecurityRulesApiClient(null as unknown as FirebaseApp))
78+
.to.throw('First argument passed to admin.securityRules() must be a valid Firebase app');
7179
});
7280
});
7381

@@ -87,6 +95,11 @@ describe('SecurityRulesApiClient', () => {
8795
'message', 'Ruleset name must not contain any "/" characters.');
8896
});
8997

98+
it(`should reject when project id is not available`, () => {
99+
return clientWithoutProjectId.getRuleset(RULESET_NAME)
100+
.should.eventually.be.rejectedWith(noProjectId);
101+
});
102+
90103
it('should resolve with the requested ruleset on success', () => {
91104
const stub = sinon
92105
.stub(HttpClient.prototype, 'send')
@@ -166,6 +179,14 @@ describe('SecurityRulesApiClient', () => {
166179
});
167180
});
168181

182+
it(`should reject when project id is not available`, () => {
183+
return clientWithoutProjectId.createRuleset({
184+
source: {
185+
files: [RULES_FILE],
186+
},
187+
}).should.eventually.be.rejectedWith(noProjectId);
188+
});
189+
169190
const invalidFiles: any[] = [null, undefined, 'test', {}, { name: 'test' }, { content: 'test' }];
170191
invalidFiles.forEach((file) => {
171192
it(`should reject when called with: ${JSON.stringify(file)}`, () => {
@@ -306,6 +327,11 @@ describe('SecurityRulesApiClient', () => {
306327
});
307328
});
308329

330+
it(`should reject when project id is not available`, () => {
331+
return clientWithoutProjectId.listRulesets()
332+
.should.eventually.be.rejectedWith(noProjectId);
333+
});
334+
309335
it('should resolve on success when called without any arguments', () => {
310336
const stub = sinon
311337
.stub(HttpClient.prototype, 'send')
@@ -400,6 +426,11 @@ describe('SecurityRulesApiClient', () => {
400426
});
401427

402428
describe('getRelease', () => {
429+
it(`should reject when project id is not available`, () => {
430+
return clientWithoutProjectId.getRelease(RELEASE_NAME)
431+
.should.eventually.be.rejectedWith(noProjectId);
432+
});
433+
403434
it('should resolve with the requested release on success', () => {
404435
const stub = sinon
405436
.stub(HttpClient.prototype, 'send')
@@ -459,6 +490,11 @@ describe('SecurityRulesApiClient', () => {
459490
});
460491

461492
describe('updateRelease', () => {
493+
it(`should reject when project id is not available`, () => {
494+
return clientWithoutProjectId.updateRelease(RELEASE_NAME, RULESET_NAME)
495+
.should.eventually.be.rejectedWith(noProjectId);
496+
});
497+
462498
it('should resolve with the updated release on success', () => {
463499
const stub = sinon
464500
.stub(HttpClient.prototype, 'send')
@@ -539,6 +575,11 @@ describe('SecurityRulesApiClient', () => {
539575
'message', 'Ruleset name must not contain any "/" characters.');
540576
});
541577

578+
it(`should reject when project id is not available`, () => {
579+
return clientWithoutProjectId.deleteRuleset(RULESET_NAME)
580+
.should.eventually.be.rejectedWith(noProjectId);
581+
});
582+
542583
it('should resolve on success', () => {
543584
const stub = sinon
544585
.stub(HttpClient.prototype, 'send')

test/unit/security-rules/security-rules.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,16 @@ describe('SecurityRules', () => {
128128
+ 'instance.');
129129
});
130130

131-
it('should throw when initialized without project ID', () => {
131+
it('should reject when initialized without project ID', () => {
132132
// Project ID not set in the environment.
133133
delete process.env.GOOGLE_CLOUD_PROJECT;
134134
delete process.env.GCLOUD_PROJECT;
135135
const noProjectId = 'Failed to determine project ID. Initialize the SDK with service '
136136
+ 'account credentials, or set project ID as an app option. Alternatively, set the '
137137
+ 'GOOGLE_CLOUD_PROJECT environment variable.';
138-
expect(() => {
139-
return new SecurityRules(mockCredentialApp);
140-
}).to.throw(noProjectId);
138+
const rulesWithoutProjectId = new SecurityRules(mockCredentialApp);
139+
return rulesWithoutProjectId.getRuleset('test')
140+
.should.eventually.rejectedWith(noProjectId);
141141
});
142142

143143
it('should not throw given a valid app', () => {

0 commit comments

Comments
 (0)