"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RulesDeploy = exports.RulesetServiceType = void 0; const _ = require("lodash"); const colorette_1 = require("colorette"); const fs = require("fs-extra"); const gcp = require("./gcp"); const logger_1 = require("./logger"); const error_1 = require("./error"); const utils = require("./utils"); const prompt_1 = require("./prompt"); const getProjectNumber_1 = require("./getProjectNumber"); const resourceManager_1 = require("./gcp/resourceManager"); const QUOTA_EXCEEDED_STATUS_CODE = 429; const RULESET_COUNT_LIMIT = 1000; const RULESETS_TO_GC = 10; const CROSS_SERVICE_FUNCTIONS = /firestore\.(get|exists)/; const CROSS_SERVICE_RULES_ROLE = "roles/firebaserules.firestoreServiceAgent"; var RulesetServiceType; (function (RulesetServiceType) { RulesetServiceType["CLOUD_FIRESTORE"] = "cloud.firestore"; RulesetServiceType["FIREBASE_STORAGE"] = "firebase.storage"; })(RulesetServiceType = exports.RulesetServiceType || (exports.RulesetServiceType = {})); const RulesetType = { [RulesetServiceType.CLOUD_FIRESTORE]: "firestore", [RulesetServiceType.FIREBASE_STORAGE]: "storage", }; class RulesDeploy { constructor(options, type) { this.options = options; this.type = type; this.project = options.project; this.rulesFiles = {}; this.rulesetNames = {}; } addFile(path) { const fullPath = this.options.config.path(path); let src; try { src = fs.readFileSync(fullPath, "utf8"); } catch (e) { logger_1.logger.debug("[rules read error]", e.stack); throw new error_1.FirebaseError(`Error reading rules file ${(0, colorette_1.bold)(path)}`); } this.rulesFiles[path] = [{ name: path, content: src }]; } async compile() { await Promise.all(Object.keys(this.rulesFiles).map((filename) => { return this.compileRuleset(filename, this.rulesFiles[filename]); })); } async getCurrentRules(service) { const latestName = await gcp.rules.getLatestRulesetName(this.options.project, service); let latestContent = null; if (latestName) { latestContent = await gcp.rules.getRulesetContent(latestName); } return { latestName, latestContent }; } async checkStorageRulesIamPermissions(rulesContent) { if ((rulesContent === null || rulesContent === void 0 ? void 0 : rulesContent.match(CROSS_SERVICE_FUNCTIONS)) === null) { return; } if (this.options.nonInteractive) { return; } const projectNumber = await (0, getProjectNumber_1.getProjectNumber)(this.options); const saEmail = `service-${projectNumber}@gcp-sa-firebasestorage.iam.gserviceaccount.com`; try { if (await (0, resourceManager_1.serviceAccountHasRoles)(projectNumber, saEmail, [CROSS_SERVICE_RULES_ROLE], true)) { return; } const addRole = await (0, prompt_1.promptOnce)({ type: "confirm", name: "rulesRole", message: `Cloud Storage for Firebase needs an IAM Role to use cross-service rules. Grant the new role?`, default: true, }, this.options); if (addRole) { await (0, resourceManager_1.addServiceAccountToRoles)(projectNumber, saEmail, [CROSS_SERVICE_RULES_ROLE], true); utils.logLabeledBullet(RulesetType[this.type], "updated service account for cross-service rules..."); } } catch (e) { logger_1.logger.warn("[rules] Error checking or updating Cloud Storage for Firebase service account permissions."); logger_1.logger.warn("[rules] Cross-service Storage rules may not function properly", e.message); } } async createRulesets(service) { var _a; const createdRulesetNames = []; const { latestName: latestRulesetName, latestContent: latestRulesetContent } = await this.getCurrentRules(service); const newRulesetsByFilename = new Map(); for (const [filename, files] of Object.entries(this.rulesFiles)) { if (latestRulesetName && _.isEqual(files, latestRulesetContent)) { utils.logLabeledBullet(RulesetType[this.type], `latest version of ${(0, colorette_1.bold)(filename)} already up to date, skipping upload...`); this.rulesetNames[filename] = latestRulesetName; continue; } if (service === RulesetServiceType.FIREBASE_STORAGE) { await this.checkStorageRulesIamPermissions((_a = files[0]) === null || _a === void 0 ? void 0 : _a.content); } utils.logLabeledBullet(RulesetType[this.type], `uploading rules ${(0, colorette_1.bold)(filename)}...`); newRulesetsByFilename.set(filename, gcp.rules.createRuleset(this.options.project, files)); } try { await Promise.all(newRulesetsByFilename.values()); for (const [filename, rulesetName] of newRulesetsByFilename) { this.rulesetNames[filename] = await rulesetName; createdRulesetNames.push(await rulesetName); } } catch (err) { if (err.status !== QUOTA_EXCEEDED_STATUS_CODE) { throw err; } utils.logLabeledBullet(RulesetType[this.type], "quota exceeded error while uploading rules"); const history = await gcp.rules.listAllRulesets(this.options.project); if (history.length > RULESET_COUNT_LIMIT) { const confirm = await (0, prompt_1.promptOnce)({ type: "confirm", name: "force", message: `You have ${history.length} rules, do you want to delete the oldest ${RULESETS_TO_GC} to free up space?`, default: false, }, this.options); if (confirm) { const releases = await gcp.rules.listAllReleases(this.options.project); const unreleased = history.filter((ruleset) => { return !releases.find((release) => release.rulesetName === ruleset.name); }); const entriesToDelete = unreleased.reverse().slice(0, RULESETS_TO_GC); for (const entry of entriesToDelete) { await gcp.rules.deleteRuleset(this.options.project, gcp.rules.getRulesetId(entry)); logger_1.logger.debug(`[rules] Deleted ${entry.name}`); } utils.logLabeledWarning(RulesetType[this.type], "retrying rules upload"); return this.createRulesets(service); } } } return createdRulesetNames; } async release(filename, resourceName, subResourceName) { if (resourceName === RulesetServiceType.FIREBASE_STORAGE && !subResourceName) { throw new error_1.FirebaseError(`Cannot release resource type "${resourceName}"`); } await gcp.rules.updateOrCreateRelease(this.options.project, this.rulesetNames[filename], resourceName === RulesetServiceType.FIREBASE_STORAGE ? `${resourceName}/${subResourceName}` : resourceName); utils.logLabeledSuccess(RulesetType[this.type], `released rules ${(0, colorette_1.bold)(filename)} to ${(0, colorette_1.bold)(resourceName)}`); } async compileRuleset(filename, files) { utils.logLabeledBullet(this.type, `checking ${(0, colorette_1.bold)(filename)} for compilation errors...`); const response = await gcp.rules.testRuleset(this.options.project, files); if (_.get(response, "body.issues", []).length) { const warnings = []; const errors = []; response.body.issues.forEach((issue) => { const issueMessage = `[${issue.severity.substring(0, 1)}] ${issue.sourcePosition.line}:${issue.sourcePosition.column} - ${issue.description}`; if (issue.severity === "ERROR") { errors.push(issueMessage); } else { warnings.push(issueMessage); } }); if (warnings.length > 0) { warnings.forEach((warning) => { utils.logWarning(warning); }); } if (errors.length > 0) { const add = errors.length === 1 ? "" : "s"; const message = `Compilation error${add} in ${(0, colorette_1.bold)(filename)}:\n${errors.join("\n")}`; throw new error_1.FirebaseError(message, { exit: 1 }); } } utils.logLabeledSuccess(this.type, `rules file ${(0, colorette_1.bold)(filename)} compiled successfully`); } } exports.RulesDeploy = RulesDeploy;