diff --git a/src/lib/secret.ts b/src/lib/secret.ts new file mode 100644 index 00000000..201dd63d --- /dev/null +++ b/src/lib/secret.ts @@ -0,0 +1,51 @@ +export class Secret { + // A fauna key, like `fn1234`. + key: string; + // A database scope, like `["foo", "bar"]` + databaseScope: string[]; + + constructor(opts: { key: string; databaseScope?: string[] }) { + this.key = opts.key; + this.databaseScope = opts.databaseScope ?? []; + } + + static parse(key: string) { + if (key.length === 0) { + throw new Error("Secret cannot be empty"); + } + if (key.includes(":")) { + throw new Error("Secret cannot be scoped"); + } + return new Secret({ key }); + } + + buildSecret(opts?: { role?: string }): string { + let secret = this.key; + if (this.databaseScope.length > 0) { + secret += `:${this.databaseScope.join("/")}`; + } + if (opts?.role !== undefined || this.databaseScope.length > 0) { + const role = opts?.role ?? "admin"; + secret += ["admin", "client", "server", "server-readonly"].includes(role) + ? `:${role}` + : `:@role/${role}`; + } + return secret; + } + + /** + * Parses the given scope, appends it to the `Secret`, and returns the new + * secret. This mutates `this`. + */ + appendScope(scope: string) { + this.databaseScope.push(...scope.split("/")); + return this; + } + + clone(): Secret { + return new Secret({ + key: this.key, + databaseScope: [...this.databaseScope], + }); + } +} diff --git a/test/lib/secret.test.ts b/test/lib/secret.test.ts new file mode 100644 index 00000000..34cda3a8 --- /dev/null +++ b/test/lib/secret.test.ts @@ -0,0 +1,68 @@ +import { expect } from "chai"; +import { Secret } from "../../src/lib/secret"; + +describe("secret", () => { + it("appends paths scoped secrets", () => { + const secret = Secret.parse("fn1234"); + expect(secret.databaseScope).to.eql([]); + + secret.appendScope("foo"); + expect(secret.databaseScope).to.eql(["foo"]); + + secret.appendScope("bar"); + expect(secret.databaseScope).to.eql(["foo", "bar"]); + }); + + it("parses database paths from appendScope", () => { + const secret = Secret.parse("fn1234"); + expect(secret.databaseScope).to.eql([]); + + secret.appendScope("foo/bar"); + expect(secret.databaseScope).to.eql(["foo", "bar"]); + }); + + it("disallows empty secrets", () => { + expect(() => Secret.parse("")).to.throw("Secret cannot be empty"); + }); + + it("disallows scoped secrets", () => { + expect(() => Secret.parse("fn1234:foo")).to.throw( + "Secret cannot be scoped" + ); + }); + + it("builds a secret with a role", () => { + const secret = Secret.parse("fn1234"); + expect(secret.buildSecret()).to.equal("fn1234"); + expect(secret.buildSecret({ role: "admin" })).to.equal("fn1234:admin"); + expect(secret.buildSecret({ role: "server" })).to.equal("fn1234:server"); + expect(secret.buildSecret({ role: "server-readonly" })).to.equal( + "fn1234:server-readonly" + ); + expect(secret.buildSecret({ role: "client" })).to.equal("fn1234:client"); + expect(secret.buildSecret({ role: "foo" })).to.equal("fn1234:@role/foo"); + }); + + it("builds a secret with a scope", () => { + const secret = Secret.parse("fn1234"); + secret.appendScope("foo"); + expect(secret.buildSecret()).to.equal("fn1234:foo:admin"); + secret.appendScope("bar"); + expect(secret.buildSecret()).to.equal("fn1234:foo/bar:admin"); + }); + + it("builds a secret with a scope and role", () => { + const secret = Secret.parse("fn1234"); + secret.appendScope("foo/bar"); + expect(secret.buildSecret()).to.equal("fn1234:foo/bar:admin"); + expect(secret.buildSecret({ role: "admin" })).to.equal( + "fn1234:foo/bar:admin" + ); + expect(secret.buildSecret({ role: "server" })).to.equal( + "fn1234:foo/bar:server" + ); + expect(secret.buildSecret({ role: "foo" })).to.equal( + "fn1234:foo/bar:@role/foo" + ); + }); +});