~james/core

core/web/src/lib/auth/scopes.ts -rw-r--r-- 2.6 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import type { Did, Nsid } from "@atcute/lexicons/syntax";

type RepoAction = "create" | "update" | "delete";

export type Permission =
	| {
			readonly resource: "repo";
			readonly collection: Nsid;
			readonly actions: readonly RepoAction[];
	  }
	| { readonly resource: "rpc"; readonly lxm: Nsid; readonly aud: Did }
	| { readonly resource: "identity"; readonly attr: "handle" };

const ANY = "*";

const ALL_ACTIONS: readonly RepoAction[] = ["create", "update", "delete"];

interface Grant {
	readonly resource: string;
	readonly positional: string | null;
	readonly params: URLSearchParams;
}

const grantFrom = (scope: string): Grant => {
	const query = scope.indexOf("?");
	const head = query < 0 ? scope : scope.slice(0, query);
	const params = new URLSearchParams(query < 0 ? "" : scope.slice(query + 1));
	const colon = head.indexOf(":");
	return colon < 0
		? { resource: head, positional: null, params }
		: { resource: head.slice(0, colon), positional: head.slice(colon + 1), params };
};

const listOf = (grant: Grant, name: string): readonly string[] =>
	grant.positional === null
		? grant.params.getAll(name)
		: grant.positional === ""
			? []
			: [grant.positional];

const isRepoAction = (value: string): value is RepoAction =>
	(ALL_ACTIONS as readonly string[]).includes(value);

const nameMatches = (granted: string, wanted: Nsid): boolean =>
	granted === ANY || granted === wanted;

const covers = (grant: Grant, wanted: Permission): boolean => {
	if (grant.resource !== wanted.resource) return false;
	if (wanted.resource === "identity") {
		return listOf(grant, "attr").some((attr) => attr === ANY || attr === wanted.attr);
	}
	if (wanted.resource === "repo") {
		const asked = grant.params.getAll("action");
		const held = asked.length === 0 ? ALL_ACTIONS : asked.filter(isRepoAction);
		return (
			listOf(grant, "collection").some((granted) =>
				nameMatches(granted, wanted.collection)
			) && wanted.actions.every((action) => held.includes(action))
		);
	}
	const auds = grant.params.getAll("aud");
	return (
		auds.some((aud) => aud === ANY || aud === wanted.aud) &&
		listOf(grant, "lxm").some((granted) => nameMatches(granted, wanted.lxm))
	);
};

export const missingPermissions = (
	granted: string,
	wanted: readonly Permission[]
): readonly Permission[] => {
	const scopes = granted.split(/\s+/).filter((scope) => scope !== "");
	const unreadable = scopes.some(
		(scope) => scope === "transition:generic" || scope.startsWith("include:")
	);
	if (unreadable) return [];
	const grants = scopes.map(grantFrom);
	return wanted.filter((permission) => !grants.some((grant) => covers(grant, permission)));
};