diff --git a/packages/ui/docs-bundle/src/server/__test__/withRbac.test.ts b/packages/ui/docs-bundle/src/server/__test__/withRbac.test.ts index c0e81ac11d..11c5703541 100644 --- a/packages/ui/docs-bundle/src/server/__test__/withRbac.test.ts +++ b/packages/ui/docs-bundle/src/server/__test__/withRbac.test.ts @@ -1,31 +1,34 @@ import { NodeId, PageId, RoleId, Slug, Url } from "@fern-api/fdr-sdk/navigation"; -import { getViewerFilters, matchRoles, withBasicTokenAnonymous, withBasicTokenAnonymousCheck } from "../withRbac"; +import { Gate, getViewerFilters, matchRoles, withBasicTokenAnonymous, withBasicTokenAnonymousCheck } from "../withRbac"; describe("withBasicTokenAnonymous", () => { + it("should allow the request if no rules are provided", () => { + expect(withBasicTokenAnonymous({}, "/public")).toBe(Gate.ALLOW); + }); + it("should deny the request if the allowlist is empty", () => { - expect(withBasicTokenAnonymous({}, "/public")).toBe(true); - expect(withBasicTokenAnonymous({ allowlist: [] }, "/public")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: [] }, "/public")).toBe(Gate.DENY); }); it("should allow the request to pass through if the path is in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public"] }, "/public")).toBe(false); + expect(withBasicTokenAnonymous({ allowlist: ["/public"] }, "/public")).toBe(Gate.ALLOW); }); it("should allow the request to pass through if the path matches a regex in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(false); + expect(withBasicTokenAnonymous({ allowlist: ["/public/(.*)"] }, "/public/123")).toBe(Gate.ALLOW); }); it("should allow the request to pass through if the path matches a path expression in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public/:id"] }, "/public/123")).toBe(false); + expect(withBasicTokenAnonymous({ allowlist: ["/public/:id"] }, "/public/123")).toBe(Gate.ALLOW); }); it("should not allow the request to pass through if the path is not in the allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(true); - expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private")).toBe(Gate.DENY); + expect(withBasicTokenAnonymous({ allowlist: ["/public", "/public/:id"] }, "/private/123")).toBe(Gate.DENY); }); it("shouuld respect denylist before allowlist", () => { - expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(true); + expect(withBasicTokenAnonymous({ allowlist: ["/public"], denylist: ["/public"] }, "/public")).toBe(Gate.DENY); }); }); @@ -39,29 +42,7 @@ describe("withBasicTokenAnonymousCheck", () => { icon: undefined, id: NodeId("1"), }), - ).toBe(false); - }); - - it("should ignore childless non-leaf nodes", () => { - expect( - withBasicTokenAnonymousCheck({ allowlist: ["/public"] })({ - type: "section", - title: "Public", - children: [], - id: NodeId("1"), - slug: Slug("public"), - collapsed: false, - canonicalSlug: undefined, - icon: undefined, - hidden: undefined, - authed: undefined, - overviewPageId: undefined, - noindex: undefined, - pointsTo: undefined, - viewers: undefined, - orphaned: undefined, - }), - ).toBe(false); + ).toBe(Gate.ALLOW); }); it("should not prune childless non-leaf nodes that have content", () => { @@ -83,52 +64,52 @@ describe("withBasicTokenAnonymousCheck", () => { viewers: undefined, orphaned: undefined, }), - ).toBe(false); + ).toBe(Gate.ALLOW); }); }); describe("matchRoles", () => { it("should return true if the audience is empty", () => { - expect(matchRoles([], [])).toBe(true); - expect(matchRoles([], [[], []])).toBe(true); + expect(matchRoles(true, [], [])).toBe(Gate.ALLOW); + expect(matchRoles(true, [], [[], []])).toBe(Gate.ALLOW); }); it("should return false if an audience filter exists", () => { - expect(matchRoles([], [["a"]])).toBe(false); + expect(matchRoles(true, [], [["a"]])).toBe(Gate.DENY); }); it("should return true if the role is everyone", () => { - expect(matchRoles([], [["everyone"]])).toBe(true); + expect(matchRoles(true, [], [["everyone"]])).toBe(Gate.ALLOW); }); it("should return true if the audience matches the filter", () => { - expect(matchRoles(["a"], [["a"]])).toBe(true); + expect(matchRoles(true, ["a"], [["a"]])).toBe(Gate.ALLOW); }); it("should return true if the audience matches any of the filters", () => { - expect(matchRoles(["a"], [["b", "a"]])).toBe(true); + expect(matchRoles(true, ["a"], [["b", "a"]])).toBe(Gate.ALLOW); }); it("should return false if the audience does not match any of the filters", () => { - expect(matchRoles(["a"], [["b"]])).toBe(false); + expect(matchRoles(true, ["a"], [["b"]])).toBe(Gate.DENY); }); it("should return false if the audience does not match all filters across all nodes", () => { - expect(matchRoles(["a"], [["a"], ["b"]])).toBe(false); - expect(matchRoles(["b"], [["a"], ["a", "b"]])).toBe(false); + expect(matchRoles(true, ["a"], [["a"], ["b"]])).toBe(Gate.DENY); + expect(matchRoles(true, ["b"], [["a"], ["a", "b"]])).toBe(Gate.DENY); }); it("should return true if the audience matches all filters across all nodes", () => { - expect(matchRoles(["a"], [["a"], ["a"]])).toBe(true); - expect(matchRoles(["a"], [["a"], ["a", "b"]])).toBe(true); - expect(matchRoles(["a", "b"], [["a"], ["a", "b"]])).toBe(true); - expect(matchRoles(["a", "b"], [["a"], ["b"]])).toBe(true); + expect(matchRoles(true, ["a"], [["a"], ["a"]])).toBe(Gate.ALLOW); + expect(matchRoles(true, ["a"], [["a"], ["a", "b"]])).toBe(Gate.ALLOW); + expect(matchRoles(true, ["a", "b"], [["a"], ["a", "b"]])).toBe(Gate.ALLOW); + expect(matchRoles(true, ["a", "b"], [["a"], ["b"]])).toBe(Gate.ALLOW); }); it("should return true if the user has more audiences than the filter", () => { - expect(matchRoles(["a", "b"], [])).toBe(true); - expect(matchRoles(["a", "b"], [[]])).toBe(true); - expect(matchRoles(["a", "b"], [["a"]])).toBe(true); + expect(matchRoles(true, ["a", "b"], [])).toBe(Gate.ALLOW); + expect(matchRoles(true, ["a", "b"], [[]])).toBe(Gate.ALLOW); + expect(matchRoles(true, ["a", "b"], [["a"]])).toBe(Gate.ALLOW); }); }); diff --git a/packages/ui/docs-bundle/src/server/withRbac.ts b/packages/ui/docs-bundle/src/server/withRbac.ts index b3ab773479..6bf8b53fff 100644 --- a/packages/ui/docs-bundle/src/server/withRbac.ts +++ b/packages/ui/docs-bundle/src/server/withRbac.ts @@ -12,6 +12,11 @@ import type { AuthEdgeConfigBasicTokenVerification } from "@fern-ui/fern-docs-au import { EVERYONE_ROLE, matchPath } from "@fern-ui/fern-docs-utils"; import { addLeadingSlash } from "./addLeadingSlash"; +export enum Gate { + ALLOW, + DENY, +} + interface AuthRulesPathName { /** * List of paths that should be allowed to pass through without authentication @@ -30,17 +35,17 @@ interface AuthRulesPathName { } /** - * @returns true if the request should should be marked as authed + * @returns true if the request should should be denied */ -export function withBasicTokenAnonymous(auth: AuthRulesPathName, pathname: string): boolean { +export function withBasicTokenAnonymous(auth: AuthRulesPathName, pathname: string): Gate { // if there are no auth rules, allow the request to pass through if (auth.allowlist == null && auth.denylist == null && auth.anonymous == null) { - return false; + return Gate.ALLOW; } // if the path is in the denylist, deny the request if (auth.denylist?.find((path) => matchPath(path, pathname))) { - return true; + return Gate.DENY; } // if the path is in the allowlist, allow the request to pass through @@ -48,11 +53,11 @@ export function withBasicTokenAnonymous(auth: AuthRulesPathName, pathname: strin auth.allowlist?.find((path) => matchPath(path, pathname)) || auth.anonymous?.find((path) => matchPath(path, pathname)) ) { - return false; + return Gate.ALLOW; } // if the path is not in the allowlist, deny the request - return true; + return Gate.DENY; } /** @@ -60,24 +65,29 @@ export function withBasicTokenAnonymous(auth: AuthRulesPathName, pathname: strin */ export function withBasicTokenAnonymousCheck( auth: AuthRulesPathName, -): (node: NavigationNode, parents?: readonly NavigationNodeParent[]) => boolean { +): (node: NavigationNode, parents?: readonly NavigationNodeParent[]) => Gate { return (node, parents = EMPTY_ARRAY) => { - if (!rbacViewPredicate([], false)(node, parents)) { - return false; + if (isPage(node) && withBasicTokenAnonymous(auth, addLeadingSlash(node.slug)) === Gate.ALLOW) { + return Gate.ALLOW; } - if (isPage(node)) { - return withBasicTokenAnonymous(auth, addLeadingSlash(node.slug)); - } - - return false; + const predicate = rbacViewGate([], false); + return predicate(node, parents); }; } +function withDenied Gate>(predicate: T): (...args: Parameters) => boolean { + return (...args) => predicate(...args) === Gate.DENY; +} + +function withAllowed Gate>(predicate: T): (...args: Parameters) => boolean { + return (...args) => predicate(...args) === Gate.ALLOW; +} + export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerification, node: RootNode): RootNode { const result = Pruner.from(node) // mark nodes that are authed - .authed(withBasicTokenAnonymousCheck(auth)) + .authed(withDenied(withBasicTokenAnonymousCheck(auth))) .get(); // TODO: handle this more gracefully @@ -92,20 +102,24 @@ export function pruneWithBasicTokenAnonymous(auth: AuthEdgeConfigBasicTokenVerif * @internal * @param roles current viewer's roles * @param filters rbac filters for the current node + * @param authed whether the viewer is authenticated * @returns true if the roles matches the filters (i.e. the viewer is allowed to view the node) */ -export function matchRoles(roles: string[], filters: string[][]): boolean { - if (filters.length === 0 || filters.every((filter) => filter.length === 0)) { - return true; +export function matchRoles(authed: boolean, roles: string[], filters: string[][]): Gate { + roles = [EVERYONE_ROLE, ...roles]; + + // filters must include "everyone" if the viewer is authenticated + if (authed && (filters.length === 0 || filters.every((filter) => filter.length === 0))) { + return Gate.ALLOW; } - return filters.every((filter) => filter.some((aud) => roles.includes(aud) || aud === EVERYONE_ROLE)); + return filters.every((filter) => filter.some((aud) => roles.includes(aud))) ? Gate.ALLOW : Gate.DENY; } export function pruneWithBasicTokenAuthed(auth: AuthRulesPathName, node: RootNode, roles: string[] = []): RootNode { const result = Pruner.from(node) // apply rbac - .keep(rbacViewPredicate(roles, true)) + .keep(withAllowed(rbacViewGate(roles, true))) // hide nodes that are not authed .hide((n) => node.hidden || auth.anonymous?.find((path) => matchPath(path, addLeadingSlash(n.slug))) != null) // mark all nodes as unauthed since we are currently authenticated @@ -137,20 +151,22 @@ export function getViewerFilters(...nodes: FernNavigation.WithPermissions[]): st ); } -function rbacViewPredicate( +function rbacViewGate( roles: string[], authed: boolean, -): (node: NavigationNode, parents: readonly NavigationNodeParent[]) => boolean { +): (node: NavigationNode, parents: readonly NavigationNodeParent[]) => Gate { return (node, parents) => { if (!hasMetadata(node)) { - return true; + return Gate.ALLOW; } if (!authed && node.authed) { - return false; + return Gate.DENY; } const nodes = [...parents, node]; - return matchRoles(roles, getViewerFilters(...nodes.filter(FernNavigation.hasMetadata))); + const filters = getViewerFilters(...nodes.filter(FernNavigation.hasMetadata)); + + return matchRoles(authed, roles, filters); }; }