Skip to content

Commit

Permalink
update rbac logic
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity committed Oct 25, 2024
1 parent 39b0286 commit 24ea157
Show file tree
Hide file tree
Showing 2 changed files with 71 additions and 74 deletions.
79 changes: 30 additions & 49 deletions packages/ui/docs-bundle/src/server/__test__/withRbac.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

Expand All @@ -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", () => {
Expand All @@ -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);
});
});

Expand Down
66 changes: 41 additions & 25 deletions packages/ui/docs-bundle/src/server/withRbac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,54 +35,59 @@ 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
if (
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;
}

/**
* @internal visibleForTesting
*/
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<T extends (...args: any[]) => Gate>(predicate: T): (...args: Parameters<T>) => boolean {
return (...args) => predicate(...args) === Gate.DENY;
}

function withAllowed<T extends (...args: any[]) => Gate>(predicate: T): (...args: Parameters<T>) => 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
Expand All @@ -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
Expand Down Expand Up @@ -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);
};
}

0 comments on commit 24ea157

Please sign in to comment.