@dwk/wac 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +48 -0
- package/dist/index.d.ts +129 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +220 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/index.ts +349 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David W. Keith
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# `@dwk/wac`
|
|
2
|
+
|
|
3
|
+
Web Access Control (WAC) evaluation for Solid Pods. A pure, dependency-light
|
|
4
|
+
library consumed by [`@dwk/solid-pod`](../solid-pod): it takes ACL graphs (as
|
|
5
|
+
plain quads, compatible with [`@dwk/rdf`](../rdf) terms) plus request facts and
|
|
6
|
+
returns an authorization decision. No Cloudflare bindings; unit-testable without
|
|
7
|
+
a Workers runtime.
|
|
8
|
+
|
|
9
|
+
See the [spec](../../spec/packages/wac.md) for the authoritative requirements,
|
|
10
|
+
and the [Solid WAC specification](https://solidproject.org/TR/wac) for the model.
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
14
|
+
- **Effective-ACL walk** — resolves the nearest applicable `.acl`, honoring
|
|
15
|
+
`acl:default` on ancestor containers. A resource's own `acl:accessTo` ACL
|
|
16
|
+
takes precedence over inherited `acl:default` ACLs.
|
|
17
|
+
- **Modes** — `acl:Read`, `acl:Write`, `acl:Append`, `acl:Control`.
|
|
18
|
+
- **Subjects** — `acl:agent`, `acl:agentGroup`, and `acl:agentClass`
|
|
19
|
+
(including `foaf:Agent` for public access and `acl:AuthenticatedAgent`).
|
|
20
|
+
- **Origin** — `acl:origin` acts as a per-authorization allow-list.
|
|
21
|
+
- **Append vs Write** — an `append` request is satisfied by `acl:Append` _or_
|
|
22
|
+
`acl:Write`; a delete must be requested as `write`, which `acl:Append` alone
|
|
23
|
+
never grants.
|
|
24
|
+
|
|
25
|
+
> Decisions MUST NOT be memoized in eventually-consistent stores (e.g. KV); the
|
|
26
|
+
> walk is cheap and is meant to run against strongly-consistent ACL state.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { evaluateAccess, type AclResource } from "@dwk/wac";
|
|
32
|
+
|
|
33
|
+
// Build the effective-ACL chain, nearest first. The requested resource's own
|
|
34
|
+
// ACL uses scope "accessTo"; ancestor container ACLs use scope "default".
|
|
35
|
+
const chain: AclResource[] = [
|
|
36
|
+
{ target: "https://alice.example/notes/secret.ttl", scope: "accessTo", quads: resourceAclQuads },
|
|
37
|
+
{ target: "https://alice.example/notes/", scope: "default", quads: containerAclQuads },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const decision = evaluateAccess(
|
|
41
|
+
{ mode: "write", agent: "https://bob.example/card#me", origin: "https://app.example" },
|
|
42
|
+
chain,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (!decision.granted) {
|
|
46
|
+
// 401 if unauthenticated, otherwise 403.
|
|
47
|
+
}
|
|
48
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/wac` — Web Access Control (WAC) evaluation for Solid Pods.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Pure library: takes ACL graphs and request facts as plain data and returns an
|
|
6
|
+
* authorization decision. Tied to the Solid/WAC model by design, but carries no
|
|
7
|
+
* Cloudflare bindings and is unit-testable without a Workers runtime.
|
|
8
|
+
*
|
|
9
|
+
* The evaluator performs the effective-ACL walk (resolving the nearest
|
|
10
|
+
* applicable `.acl`, honoring `acl:default` on ancestor containers), evaluates
|
|
11
|
+
* `acl:Read`, `acl:Write`, `acl:Append`, and `acl:Control`, and supports
|
|
12
|
+
* `acl:agent`, `acl:agentGroup`, `acl:agentClass` (including `foaf:Agent` and
|
|
13
|
+
* `acl:AuthenticatedAgent`), and `acl:origin`.
|
|
14
|
+
*
|
|
15
|
+
* Callers MUST NOT memoize decisions in eventually-consistent stores; the walk
|
|
16
|
+
* is cheap and is meant to run against strongly-consistent ACL state.
|
|
17
|
+
*
|
|
18
|
+
* @see {@link https://solidproject.org/TR/wac | Solid WAC specification}
|
|
19
|
+
* @see spec/packages/wac.md
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
import type { StoredTerm } from "@dwk/rdf";
|
|
23
|
+
/**
|
|
24
|
+
* A plain RDF triple for ACL evaluation.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Subject and predicate are always IRIs; the object may be an IRI (named node)
|
|
28
|
+
* shorthand string or a full {@link StoredTerm} from `@dwk/rdf`. WAC only
|
|
29
|
+
* matches named-node objects, so literals and blank nodes never match.
|
|
30
|
+
*/
|
|
31
|
+
export interface AclQuad {
|
|
32
|
+
/** Subject IRI. */
|
|
33
|
+
subject: string;
|
|
34
|
+
/** Predicate IRI. */
|
|
35
|
+
predicate: string;
|
|
36
|
+
/** Object: an IRI string, or a `@dwk/rdf` term. */
|
|
37
|
+
object: string | StoredTerm;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* An access mode that may be requested or granted.
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* `append` is implied by `write`: a request for `append` is satisfied by either
|
|
44
|
+
* an `acl:Append` or an `acl:Write` grant. A delete (which is not insert-only)
|
|
45
|
+
* MUST be requested as `write`, never `append`.
|
|
46
|
+
*/
|
|
47
|
+
export type AccessMode = "read" | "write" | "append" | "control";
|
|
48
|
+
/** How an ACL document's authorizations attach to a resource. */
|
|
49
|
+
export type AclScope = "accessTo" | "default";
|
|
50
|
+
/**
|
|
51
|
+
* A single ACL document in the effective-ACL chain, as plain RDF quads.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* The chain is ordered nearest-first: the requested resource's own ACL (with
|
|
55
|
+
* {@link AclScope | scope} `"accessTo"`) comes first, followed by ancestor
|
|
56
|
+
* container ACLs (scope `"default"`) from closest to farthest. **Only ACL
|
|
57
|
+
* documents that exist (have a representation) are included.**
|
|
58
|
+
*
|
|
59
|
+
* Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
|
|
60
|
+
* exists, "regardless of whether it contains matching authorizations". Because
|
|
61
|
+
* every chain entry represents an existing ACL document, the **first entry is
|
|
62
|
+
* the effective ACL and is authoritative**: {@link evaluateAccess} decides from
|
|
63
|
+
* it alone — granted or denied — and never falls through (fail open) to a
|
|
64
|
+
* farther ancestor's `acl:default`. Selecting *which* ancestor's ACL exists is
|
|
65
|
+
* the caller's job; this library does not climb the hierarchy by inspecting
|
|
66
|
+
* authorization content. Entries after the first are not consulted, so callers
|
|
67
|
+
* SHOULD pass exactly the single effective ACL.
|
|
68
|
+
*/
|
|
69
|
+
export interface AclResource {
|
|
70
|
+
/**
|
|
71
|
+
* The IRI this ACL document governs: the requested resource for an
|
|
72
|
+
* `accessTo` entry, or the container for a `default` entry. Authorizations
|
|
73
|
+
* are matched against this IRI via the scope predicate.
|
|
74
|
+
*/
|
|
75
|
+
target: string;
|
|
76
|
+
/** Which predicate links authorizations in this document to {@link target}. */
|
|
77
|
+
scope: AclScope;
|
|
78
|
+
/** The ACL document's statements, consumed from `@dwk/rdf`. */
|
|
79
|
+
quads: AclQuad[];
|
|
80
|
+
}
|
|
81
|
+
/** Facts about the agent and request being authorized. */
|
|
82
|
+
export interface AccessRequest {
|
|
83
|
+
/** The requested access mode. */
|
|
84
|
+
mode: AccessMode;
|
|
85
|
+
/** The authenticated agent's WebID, if any. Absence means unauthenticated. */
|
|
86
|
+
agent?: string;
|
|
87
|
+
/** Group IRIs the agent is a member of, matched against `acl:agentGroup`. */
|
|
88
|
+
groups?: string[];
|
|
89
|
+
/** The request `Origin`, matched against `acl:origin` when present. */
|
|
90
|
+
origin?: string;
|
|
91
|
+
}
|
|
92
|
+
/** The outcome of an authorization evaluation. */
|
|
93
|
+
export interface AccessDecision {
|
|
94
|
+
/** Whether the requested mode is granted. */
|
|
95
|
+
granted: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* The modes granted to the agent by the effective ACL. Reports the modes
|
|
98
|
+
* literally asserted (`acl:Read`/`Write`/`Append`/`Control`); note that a
|
|
99
|
+
* `write` grant also satisfies an `append` request even when `append` is not
|
|
100
|
+
* listed here.
|
|
101
|
+
*/
|
|
102
|
+
modes: AccessMode[];
|
|
103
|
+
/** The {@link AclResource.target} of the ACL that decided the request, if any. */
|
|
104
|
+
effectiveAcl?: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Evaluates a Web Access Control decision against an effective-ACL chain.
|
|
108
|
+
*
|
|
109
|
+
* @remarks
|
|
110
|
+
* Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
|
|
111
|
+
* exists, "regardless of whether it contains matching authorizations". The
|
|
112
|
+
* {@link AclResource | chain} lists existing ACL documents nearest-first, so its
|
|
113
|
+
* **first entry is the effective ACL and is authoritative**: the decision is
|
|
114
|
+
* made from that document alone — granted or denied — and never falls through
|
|
115
|
+
* (fail open) to a farther ancestor's `acl:default`, even when the document
|
|
116
|
+
* grants nothing for the target. This holds equally for a resource's own `.acl`
|
|
117
|
+
* (scope `"accessTo"`) and for an ancestor container's `acl:default`.
|
|
118
|
+
*
|
|
119
|
+
* Selecting *which* ancestor's ACL exists is the caller's responsibility (it is
|
|
120
|
+
* a function of resource existence, not authorization content); this library
|
|
121
|
+
* does not climb the hierarchy by inspecting authorizations, which would invert
|
|
122
|
+
* §5.1's stop condition. Entries after the first are not consulted.
|
|
123
|
+
*
|
|
124
|
+
* @param request - The agent and request facts to authorize.
|
|
125
|
+
* @param chain - The effective ACL as a one-element chain (nearest first).
|
|
126
|
+
* @returns The decision, including the granted modes and the effective ACL.
|
|
127
|
+
*/
|
|
128
|
+
export declare function evaluateAccess(request: AccessRequest, chain: AclResource[]): AccessDecision;
|
|
129
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,WAAW,OAAO;IACtB,mBAAmB;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,qBAAqB;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;CAC7B;AA4BD;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEjE,iEAAiE;AACjE,MAAM,MAAM,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;AAE9C;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,KAAK,EAAE,QAAQ,CAAC;IAChB,+DAA+D;IAC/D,KAAK,EAAE,OAAO,EAAE,CAAC;CAClB;AAED,0DAA0D;AAC1D,MAAM,WAAW,aAAa;IAC5B,iCAAiC;IACjC,IAAI,EAAE,UAAU,CAAC;IACjB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,kDAAkD;AAClD,MAAM,WAAW,cAAc;IAC7B,6CAA6C;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAgKD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,aAAa,EACtB,KAAK,EAAE,WAAW,EAAE,GACnB,cAAc,CA0BhB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/wac` — Web Access Control (WAC) evaluation for Solid Pods.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Pure library: takes ACL graphs and request facts as plain data and returns an
|
|
6
|
+
* authorization decision. Tied to the Solid/WAC model by design, but carries no
|
|
7
|
+
* Cloudflare bindings and is unit-testable without a Workers runtime.
|
|
8
|
+
*
|
|
9
|
+
* The evaluator performs the effective-ACL walk (resolving the nearest
|
|
10
|
+
* applicable `.acl`, honoring `acl:default` on ancestor containers), evaluates
|
|
11
|
+
* `acl:Read`, `acl:Write`, `acl:Append`, and `acl:Control`, and supports
|
|
12
|
+
* `acl:agent`, `acl:agentGroup`, `acl:agentClass` (including `foaf:Agent` and
|
|
13
|
+
* `acl:AuthenticatedAgent`), and `acl:origin`.
|
|
14
|
+
*
|
|
15
|
+
* Callers MUST NOT memoize decisions in eventually-consistent stores; the walk
|
|
16
|
+
* is cheap and is meant to run against strongly-consistent ACL state.
|
|
17
|
+
*
|
|
18
|
+
* @see {@link https://solidproject.org/TR/wac | Solid WAC specification}
|
|
19
|
+
* @see spec/packages/wac.md
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
/** WAC vocabulary base IRI. */
|
|
23
|
+
const ACL = "http://www.w3.org/ns/auth/acl#";
|
|
24
|
+
/** `rdf:type`. */
|
|
25
|
+
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
26
|
+
/** `foaf:Agent` — the class of all agents (public access). */
|
|
27
|
+
const FOAF_AGENT = "http://xmlns.com/foaf/0.1/Agent";
|
|
28
|
+
const ACL_AUTHORIZATION = `${ACL}Authorization`;
|
|
29
|
+
const ACL_ACCESS_TO = `${ACL}accessTo`;
|
|
30
|
+
const ACL_DEFAULT = `${ACL}default`;
|
|
31
|
+
/** Legacy alias for `acl:default`, still seen in the wild. */
|
|
32
|
+
const ACL_DEFAULT_FOR_NEW = `${ACL}defaultForNew`;
|
|
33
|
+
const ACL_AGENT = `${ACL}agent`;
|
|
34
|
+
const ACL_AGENT_GROUP = `${ACL}agentGroup`;
|
|
35
|
+
const ACL_AGENT_CLASS = `${ACL}agentClass`;
|
|
36
|
+
const ACL_MODE = `${ACL}mode`;
|
|
37
|
+
const ACL_ORIGIN = `${ACL}origin`;
|
|
38
|
+
const ACL_AUTHENTICATED_AGENT = `${ACL}AuthenticatedAgent`;
|
|
39
|
+
const MODE_IRI = {
|
|
40
|
+
read: `${ACL}Read`,
|
|
41
|
+
write: `${ACL}Write`,
|
|
42
|
+
append: `${ACL}Append`,
|
|
43
|
+
control: `${ACL}Control`,
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Returns the IRI of a quad object, or `undefined` if it is not a named node.
|
|
47
|
+
*
|
|
48
|
+
* Literals and blank nodes never match the named-node terms WAC cares about.
|
|
49
|
+
*/
|
|
50
|
+
function iri(object) {
|
|
51
|
+
if (typeof object === "string") {
|
|
52
|
+
return object;
|
|
53
|
+
}
|
|
54
|
+
return object?.termType === "NamedNode" ? object.value : undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Collects the named-node object IRIs for all `(subject, predicate, *)` quads. */
|
|
57
|
+
function collect(quads, subject, predicate) {
|
|
58
|
+
const values = [];
|
|
59
|
+
for (const q of quads) {
|
|
60
|
+
if (q.subject === subject && q.predicate === predicate) {
|
|
61
|
+
const value = iri(q.object);
|
|
62
|
+
if (value !== undefined) {
|
|
63
|
+
values.push(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return values;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Finds the authorizations in an ACL document that apply to its scoped target.
|
|
71
|
+
*/
|
|
72
|
+
function findApplicableAuthorizations(acl) {
|
|
73
|
+
if (!acl || !acl.quads) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const { quads, scope, target } = acl;
|
|
77
|
+
const subjects = new Set();
|
|
78
|
+
for (const q of quads) {
|
|
79
|
+
if (q.predicate === RDF_TYPE && iri(q.object) === ACL_AUTHORIZATION) {
|
|
80
|
+
subjects.add(q.subject);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const authorizations = [];
|
|
84
|
+
for (const subject of subjects) {
|
|
85
|
+
const scoped = quads.some((q) => {
|
|
86
|
+
if (q.subject !== subject || iri(q.object) !== target) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (scope === "accessTo") {
|
|
90
|
+
return q.predicate === ACL_ACCESS_TO;
|
|
91
|
+
}
|
|
92
|
+
return q.predicate === ACL_DEFAULT || q.predicate === ACL_DEFAULT_FOR_NEW;
|
|
93
|
+
});
|
|
94
|
+
if (!scoped) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
authorizations.push({
|
|
98
|
+
subject,
|
|
99
|
+
modes: collect(quads, subject, ACL_MODE),
|
|
100
|
+
agents: collect(quads, subject, ACL_AGENT),
|
|
101
|
+
agentGroups: collect(quads, subject, ACL_AGENT_GROUP),
|
|
102
|
+
agentClasses: collect(quads, subject, ACL_AGENT_CLASS),
|
|
103
|
+
origins: collect(quads, subject, ACL_ORIGIN),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return authorizations;
|
|
107
|
+
}
|
|
108
|
+
/** Whether an authorization applies to the requesting agent. */
|
|
109
|
+
function agentMatches(auth, request) {
|
|
110
|
+
// `foaf:Agent` is the public class — everyone, authenticated or not.
|
|
111
|
+
if (auth.agentClasses.includes(FOAF_AGENT)) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// A non-empty WebID means authenticated; an empty string is not a valid
|
|
115
|
+
// identity and MUST NOT satisfy `acl:AuthenticatedAgent` or match a
|
|
116
|
+
// (malformed) empty `acl:agent`.
|
|
117
|
+
if (request.agent) {
|
|
118
|
+
if (auth.agentClasses.includes(ACL_AUTHENTICATED_AGENT)) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (auth.agents.includes(request.agent)) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (request.groups &&
|
|
126
|
+
auth.agentGroups.some((g) => request.groups.includes(g))) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Normalizes an origin to its `scheme://host[:port]` form so that comparisons
|
|
133
|
+
* ignore case and trailing-slash differences. Falls back to the raw value when
|
|
134
|
+
* it is not a parseable absolute URL, keeping the match fail-closed.
|
|
135
|
+
*/
|
|
136
|
+
function normalizeOrigin(value) {
|
|
137
|
+
try {
|
|
138
|
+
return new URL(value).origin;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether an authorization's origin restriction admits the request.
|
|
146
|
+
*
|
|
147
|
+
* An authorization with no `acl:origin` applies regardless of origin; one with
|
|
148
|
+
* origins acts as an allow-list. Both sides are normalized via {@link URL} so a
|
|
149
|
+
* correctly-configured allow-list is not defeated by case or a trailing slash.
|
|
150
|
+
*/
|
|
151
|
+
function originMatches(auth, request) {
|
|
152
|
+
if (auth.origins.length === 0) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (request.origin === undefined) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const requestOrigin = normalizeOrigin(request.origin);
|
|
159
|
+
return auth.origins.some((o) => normalizeOrigin(o) === requestOrigin);
|
|
160
|
+
}
|
|
161
|
+
/** Whether the granted mode IRIs satisfy the requested mode. */
|
|
162
|
+
function isModeSatisfied(required, granted) {
|
|
163
|
+
if (required === "append") {
|
|
164
|
+
// Append authorizes insert-only patches and is implied by Write.
|
|
165
|
+
return granted.has(MODE_IRI.append) || granted.has(MODE_IRI.write);
|
|
166
|
+
}
|
|
167
|
+
return granted.has(MODE_IRI[required]);
|
|
168
|
+
}
|
|
169
|
+
/** Maps the granted mode IRIs back to {@link AccessMode} values. */
|
|
170
|
+
function toAccessModes(granted) {
|
|
171
|
+
return Object.keys(MODE_IRI).filter((mode) => granted.has(MODE_IRI[mode]));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Evaluates a Web Access Control decision against an effective-ACL chain.
|
|
175
|
+
*
|
|
176
|
+
* @remarks
|
|
177
|
+
* Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
|
|
178
|
+
* exists, "regardless of whether it contains matching authorizations". The
|
|
179
|
+
* {@link AclResource | chain} lists existing ACL documents nearest-first, so its
|
|
180
|
+
* **first entry is the effective ACL and is authoritative**: the decision is
|
|
181
|
+
* made from that document alone — granted or denied — and never falls through
|
|
182
|
+
* (fail open) to a farther ancestor's `acl:default`, even when the document
|
|
183
|
+
* grants nothing for the target. This holds equally for a resource's own `.acl`
|
|
184
|
+
* (scope `"accessTo"`) and for an ancestor container's `acl:default`.
|
|
185
|
+
*
|
|
186
|
+
* Selecting *which* ancestor's ACL exists is the caller's responsibility (it is
|
|
187
|
+
* a function of resource existence, not authorization content); this library
|
|
188
|
+
* does not climb the hierarchy by inspecting authorizations, which would invert
|
|
189
|
+
* §5.1's stop condition. Entries after the first are not consulted.
|
|
190
|
+
*
|
|
191
|
+
* @param request - The agent and request facts to authorize.
|
|
192
|
+
* @param chain - The effective ACL as a one-element chain (nearest first).
|
|
193
|
+
* @returns The decision, including the granted modes and the effective ACL.
|
|
194
|
+
*/
|
|
195
|
+
export function evaluateAccess(request, chain) {
|
|
196
|
+
if (!request || !chain || !Array.isArray(chain)) {
|
|
197
|
+
return { granted: false, modes: [] };
|
|
198
|
+
}
|
|
199
|
+
// The first chain entry is an existing ACL document, hence the effective ACL
|
|
200
|
+
// per §5.1; it is authoritative whether or not it carries a matching
|
|
201
|
+
// authorization, so the decision is taken from it alone (fail closed).
|
|
202
|
+
const acl = chain[0];
|
|
203
|
+
if (!acl) {
|
|
204
|
+
return { granted: false, modes: [] };
|
|
205
|
+
}
|
|
206
|
+
const granted = new Set();
|
|
207
|
+
for (const auth of findApplicableAuthorizations(acl)) {
|
|
208
|
+
if (agentMatches(auth, request) && originMatches(auth, request)) {
|
|
209
|
+
for (const mode of auth.modes) {
|
|
210
|
+
granted.add(mode);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
granted: isModeSatisfied(request.mode, granted),
|
|
216
|
+
modes: toAccessModes(granted),
|
|
217
|
+
effectiveAcl: acl.target,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dwk/wac",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Web Access Control evaluation (effective-ACL walk, Append vs Write). Solid-specific helper.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"web-access-control",
|
|
7
|
+
"wac",
|
|
8
|
+
"solid",
|
|
9
|
+
"authorization",
|
|
10
|
+
"acl",
|
|
11
|
+
"linked-data"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"author": "David W. Keith <me@dwk.io>",
|
|
16
|
+
"homepage": "https://github.com/davidwkeith/workers/tree/main/packages/wac#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/davidwkeith/workers.git",
|
|
20
|
+
"directory": "packages/wac"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src",
|
|
34
|
+
"!src/**/*.test.ts"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@dwk/rdf": "0.1.0-beta.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
45
|
+
"clean": "rm -rf dist"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/wac` — Web Access Control (WAC) evaluation for Solid Pods.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Pure library: takes ACL graphs and request facts as plain data and returns an
|
|
6
|
+
* authorization decision. Tied to the Solid/WAC model by design, but carries no
|
|
7
|
+
* Cloudflare bindings and is unit-testable without a Workers runtime.
|
|
8
|
+
*
|
|
9
|
+
* The evaluator performs the effective-ACL walk (resolving the nearest
|
|
10
|
+
* applicable `.acl`, honoring `acl:default` on ancestor containers), evaluates
|
|
11
|
+
* `acl:Read`, `acl:Write`, `acl:Append`, and `acl:Control`, and supports
|
|
12
|
+
* `acl:agent`, `acl:agentGroup`, `acl:agentClass` (including `foaf:Agent` and
|
|
13
|
+
* `acl:AuthenticatedAgent`), and `acl:origin`.
|
|
14
|
+
*
|
|
15
|
+
* Callers MUST NOT memoize decisions in eventually-consistent stores; the walk
|
|
16
|
+
* is cheap and is meant to run against strongly-consistent ACL state.
|
|
17
|
+
*
|
|
18
|
+
* @see {@link https://solidproject.org/TR/wac | Solid WAC specification}
|
|
19
|
+
* @see spec/packages/wac.md
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { StoredTerm } from "@dwk/rdf";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A plain RDF triple for ACL evaluation.
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* Subject and predicate are always IRIs; the object may be an IRI (named node)
|
|
30
|
+
* shorthand string or a full {@link StoredTerm} from `@dwk/rdf`. WAC only
|
|
31
|
+
* matches named-node objects, so literals and blank nodes never match.
|
|
32
|
+
*/
|
|
33
|
+
export interface AclQuad {
|
|
34
|
+
/** Subject IRI. */
|
|
35
|
+
subject: string;
|
|
36
|
+
/** Predicate IRI. */
|
|
37
|
+
predicate: string;
|
|
38
|
+
/** Object: an IRI string, or a `@dwk/rdf` term. */
|
|
39
|
+
object: string | StoredTerm;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** WAC vocabulary base IRI. */
|
|
43
|
+
const ACL = "http://www.w3.org/ns/auth/acl#";
|
|
44
|
+
/** `rdf:type`. */
|
|
45
|
+
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
46
|
+
/** `foaf:Agent` — the class of all agents (public access). */
|
|
47
|
+
const FOAF_AGENT = "http://xmlns.com/foaf/0.1/Agent";
|
|
48
|
+
|
|
49
|
+
const ACL_AUTHORIZATION = `${ACL}Authorization`;
|
|
50
|
+
const ACL_ACCESS_TO = `${ACL}accessTo`;
|
|
51
|
+
const ACL_DEFAULT = `${ACL}default`;
|
|
52
|
+
/** Legacy alias for `acl:default`, still seen in the wild. */
|
|
53
|
+
const ACL_DEFAULT_FOR_NEW = `${ACL}defaultForNew`;
|
|
54
|
+
const ACL_AGENT = `${ACL}agent`;
|
|
55
|
+
const ACL_AGENT_GROUP = `${ACL}agentGroup`;
|
|
56
|
+
const ACL_AGENT_CLASS = `${ACL}agentClass`;
|
|
57
|
+
const ACL_MODE = `${ACL}mode`;
|
|
58
|
+
const ACL_ORIGIN = `${ACL}origin`;
|
|
59
|
+
const ACL_AUTHENTICATED_AGENT = `${ACL}AuthenticatedAgent`;
|
|
60
|
+
|
|
61
|
+
const MODE_IRI: Record<AccessMode, string> = {
|
|
62
|
+
read: `${ACL}Read`,
|
|
63
|
+
write: `${ACL}Write`,
|
|
64
|
+
append: `${ACL}Append`,
|
|
65
|
+
control: `${ACL}Control`,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An access mode that may be requested or granted.
|
|
70
|
+
*
|
|
71
|
+
* @remarks
|
|
72
|
+
* `append` is implied by `write`: a request for `append` is satisfied by either
|
|
73
|
+
* an `acl:Append` or an `acl:Write` grant. A delete (which is not insert-only)
|
|
74
|
+
* MUST be requested as `write`, never `append`.
|
|
75
|
+
*/
|
|
76
|
+
export type AccessMode = "read" | "write" | "append" | "control";
|
|
77
|
+
|
|
78
|
+
/** How an ACL document's authorizations attach to a resource. */
|
|
79
|
+
export type AclScope = "accessTo" | "default";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A single ACL document in the effective-ACL chain, as plain RDF quads.
|
|
83
|
+
*
|
|
84
|
+
* @remarks
|
|
85
|
+
* The chain is ordered nearest-first: the requested resource's own ACL (with
|
|
86
|
+
* {@link AclScope | scope} `"accessTo"`) comes first, followed by ancestor
|
|
87
|
+
* container ACLs (scope `"default"`) from closest to farthest. **Only ACL
|
|
88
|
+
* documents that exist (have a representation) are included.**
|
|
89
|
+
*
|
|
90
|
+
* Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
|
|
91
|
+
* exists, "regardless of whether it contains matching authorizations". Because
|
|
92
|
+
* every chain entry represents an existing ACL document, the **first entry is
|
|
93
|
+
* the effective ACL and is authoritative**: {@link evaluateAccess} decides from
|
|
94
|
+
* it alone — granted or denied — and never falls through (fail open) to a
|
|
95
|
+
* farther ancestor's `acl:default`. Selecting *which* ancestor's ACL exists is
|
|
96
|
+
* the caller's job; this library does not climb the hierarchy by inspecting
|
|
97
|
+
* authorization content. Entries after the first are not consulted, so callers
|
|
98
|
+
* SHOULD pass exactly the single effective ACL.
|
|
99
|
+
*/
|
|
100
|
+
export interface AclResource {
|
|
101
|
+
/**
|
|
102
|
+
* The IRI this ACL document governs: the requested resource for an
|
|
103
|
+
* `accessTo` entry, or the container for a `default` entry. Authorizations
|
|
104
|
+
* are matched against this IRI via the scope predicate.
|
|
105
|
+
*/
|
|
106
|
+
target: string;
|
|
107
|
+
/** Which predicate links authorizations in this document to {@link target}. */
|
|
108
|
+
scope: AclScope;
|
|
109
|
+
/** The ACL document's statements, consumed from `@dwk/rdf`. */
|
|
110
|
+
quads: AclQuad[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Facts about the agent and request being authorized. */
|
|
114
|
+
export interface AccessRequest {
|
|
115
|
+
/** The requested access mode. */
|
|
116
|
+
mode: AccessMode;
|
|
117
|
+
/** The authenticated agent's WebID, if any. Absence means unauthenticated. */
|
|
118
|
+
agent?: string;
|
|
119
|
+
/** Group IRIs the agent is a member of, matched against `acl:agentGroup`. */
|
|
120
|
+
groups?: string[];
|
|
121
|
+
/** The request `Origin`, matched against `acl:origin` when present. */
|
|
122
|
+
origin?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The outcome of an authorization evaluation. */
|
|
126
|
+
export interface AccessDecision {
|
|
127
|
+
/** Whether the requested mode is granted. */
|
|
128
|
+
granted: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* The modes granted to the agent by the effective ACL. Reports the modes
|
|
131
|
+
* literally asserted (`acl:Read`/`Write`/`Append`/`Control`); note that a
|
|
132
|
+
* `write` grant also satisfies an `append` request even when `append` is not
|
|
133
|
+
* listed here.
|
|
134
|
+
*/
|
|
135
|
+
modes: AccessMode[];
|
|
136
|
+
/** The {@link AclResource.target} of the ACL that decided the request, if any. */
|
|
137
|
+
effectiveAcl?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A normalized authorization extracted from an ACL document. */
|
|
141
|
+
interface Authorization {
|
|
142
|
+
subject: string;
|
|
143
|
+
modes: string[];
|
|
144
|
+
agents: string[];
|
|
145
|
+
agentGroups: string[];
|
|
146
|
+
agentClasses: string[];
|
|
147
|
+
origins: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns the IRI of a quad object, or `undefined` if it is not a named node.
|
|
152
|
+
*
|
|
153
|
+
* Literals and blank nodes never match the named-node terms WAC cares about.
|
|
154
|
+
*/
|
|
155
|
+
function iri(object: AclQuad["object"]): string | undefined {
|
|
156
|
+
if (typeof object === "string") {
|
|
157
|
+
return object;
|
|
158
|
+
}
|
|
159
|
+
return object?.termType === "NamedNode" ? object.value : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Collects the named-node object IRIs for all `(subject, predicate, *)` quads. */
|
|
163
|
+
function collect(
|
|
164
|
+
quads: AclQuad[],
|
|
165
|
+
subject: string,
|
|
166
|
+
predicate: string,
|
|
167
|
+
): string[] {
|
|
168
|
+
const values: string[] = [];
|
|
169
|
+
for (const q of quads) {
|
|
170
|
+
if (q.subject === subject && q.predicate === predicate) {
|
|
171
|
+
const value = iri(q.object);
|
|
172
|
+
if (value !== undefined) {
|
|
173
|
+
values.push(value);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return values;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Finds the authorizations in an ACL document that apply to its scoped target.
|
|
182
|
+
*/
|
|
183
|
+
function findApplicableAuthorizations(acl: AclResource): Authorization[] {
|
|
184
|
+
if (!acl || !acl.quads) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const { quads, scope, target } = acl;
|
|
188
|
+
|
|
189
|
+
const subjects = new Set<string>();
|
|
190
|
+
for (const q of quads) {
|
|
191
|
+
if (q.predicate === RDF_TYPE && iri(q.object) === ACL_AUTHORIZATION) {
|
|
192
|
+
subjects.add(q.subject);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const authorizations: Authorization[] = [];
|
|
197
|
+
for (const subject of subjects) {
|
|
198
|
+
const scoped = quads.some((q) => {
|
|
199
|
+
if (q.subject !== subject || iri(q.object) !== target) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (scope === "accessTo") {
|
|
203
|
+
return q.predicate === ACL_ACCESS_TO;
|
|
204
|
+
}
|
|
205
|
+
return q.predicate === ACL_DEFAULT || q.predicate === ACL_DEFAULT_FOR_NEW;
|
|
206
|
+
});
|
|
207
|
+
if (!scoped) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
authorizations.push({
|
|
211
|
+
subject,
|
|
212
|
+
modes: collect(quads, subject, ACL_MODE),
|
|
213
|
+
agents: collect(quads, subject, ACL_AGENT),
|
|
214
|
+
agentGroups: collect(quads, subject, ACL_AGENT_GROUP),
|
|
215
|
+
agentClasses: collect(quads, subject, ACL_AGENT_CLASS),
|
|
216
|
+
origins: collect(quads, subject, ACL_ORIGIN),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return authorizations;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether an authorization applies to the requesting agent. */
|
|
223
|
+
function agentMatches(auth: Authorization, request: AccessRequest): boolean {
|
|
224
|
+
// `foaf:Agent` is the public class — everyone, authenticated or not.
|
|
225
|
+
if (auth.agentClasses.includes(FOAF_AGENT)) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
// A non-empty WebID means authenticated; an empty string is not a valid
|
|
229
|
+
// identity and MUST NOT satisfy `acl:AuthenticatedAgent` or match a
|
|
230
|
+
// (malformed) empty `acl:agent`.
|
|
231
|
+
if (request.agent) {
|
|
232
|
+
if (auth.agentClasses.includes(ACL_AUTHENTICATED_AGENT)) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (auth.agents.includes(request.agent)) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (
|
|
240
|
+
request.groups &&
|
|
241
|
+
auth.agentGroups.some((g) => request.groups!.includes(g))
|
|
242
|
+
) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Normalizes an origin to its `scheme://host[:port]` form so that comparisons
|
|
250
|
+
* ignore case and trailing-slash differences. Falls back to the raw value when
|
|
251
|
+
* it is not a parseable absolute URL, keeping the match fail-closed.
|
|
252
|
+
*/
|
|
253
|
+
function normalizeOrigin(value: string): string {
|
|
254
|
+
try {
|
|
255
|
+
return new URL(value).origin;
|
|
256
|
+
} catch {
|
|
257
|
+
return value;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Whether an authorization's origin restriction admits the request.
|
|
263
|
+
*
|
|
264
|
+
* An authorization with no `acl:origin` applies regardless of origin; one with
|
|
265
|
+
* origins acts as an allow-list. Both sides are normalized via {@link URL} so a
|
|
266
|
+
* correctly-configured allow-list is not defeated by case or a trailing slash.
|
|
267
|
+
*/
|
|
268
|
+
function originMatches(auth: Authorization, request: AccessRequest): boolean {
|
|
269
|
+
if (auth.origins.length === 0) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
if (request.origin === undefined) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
const requestOrigin = normalizeOrigin(request.origin);
|
|
276
|
+
return auth.origins.some((o) => normalizeOrigin(o) === requestOrigin);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether the granted mode IRIs satisfy the requested mode. */
|
|
280
|
+
function isModeSatisfied(
|
|
281
|
+
required: AccessMode,
|
|
282
|
+
granted: ReadonlySet<string>,
|
|
283
|
+
): boolean {
|
|
284
|
+
if (required === "append") {
|
|
285
|
+
// Append authorizes insert-only patches and is implied by Write.
|
|
286
|
+
return granted.has(MODE_IRI.append) || granted.has(MODE_IRI.write);
|
|
287
|
+
}
|
|
288
|
+
return granted.has(MODE_IRI[required]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Maps the granted mode IRIs back to {@link AccessMode} values. */
|
|
292
|
+
function toAccessModes(granted: ReadonlySet<string>): AccessMode[] {
|
|
293
|
+
return (Object.keys(MODE_IRI) as AccessMode[]).filter((mode) =>
|
|
294
|
+
granted.has(MODE_IRI[mode]),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Evaluates a Web Access Control decision against an effective-ACL chain.
|
|
300
|
+
*
|
|
301
|
+
* @remarks
|
|
302
|
+
* Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
|
|
303
|
+
* exists, "regardless of whether it contains matching authorizations". The
|
|
304
|
+
* {@link AclResource | chain} lists existing ACL documents nearest-first, so its
|
|
305
|
+
* **first entry is the effective ACL and is authoritative**: the decision is
|
|
306
|
+
* made from that document alone — granted or denied — and never falls through
|
|
307
|
+
* (fail open) to a farther ancestor's `acl:default`, even when the document
|
|
308
|
+
* grants nothing for the target. This holds equally for a resource's own `.acl`
|
|
309
|
+
* (scope `"accessTo"`) and for an ancestor container's `acl:default`.
|
|
310
|
+
*
|
|
311
|
+
* Selecting *which* ancestor's ACL exists is the caller's responsibility (it is
|
|
312
|
+
* a function of resource existence, not authorization content); this library
|
|
313
|
+
* does not climb the hierarchy by inspecting authorizations, which would invert
|
|
314
|
+
* §5.1's stop condition. Entries after the first are not consulted.
|
|
315
|
+
*
|
|
316
|
+
* @param request - The agent and request facts to authorize.
|
|
317
|
+
* @param chain - The effective ACL as a one-element chain (nearest first).
|
|
318
|
+
* @returns The decision, including the granted modes and the effective ACL.
|
|
319
|
+
*/
|
|
320
|
+
export function evaluateAccess(
|
|
321
|
+
request: AccessRequest,
|
|
322
|
+
chain: AclResource[],
|
|
323
|
+
): AccessDecision {
|
|
324
|
+
if (!request || !chain || !Array.isArray(chain)) {
|
|
325
|
+
return { granted: false, modes: [] };
|
|
326
|
+
}
|
|
327
|
+
// The first chain entry is an existing ACL document, hence the effective ACL
|
|
328
|
+
// per §5.1; it is authoritative whether or not it carries a matching
|
|
329
|
+
// authorization, so the decision is taken from it alone (fail closed).
|
|
330
|
+
const acl = chain[0];
|
|
331
|
+
if (!acl) {
|
|
332
|
+
return { granted: false, modes: [] };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const granted = new Set<string>();
|
|
336
|
+
for (const auth of findApplicableAuthorizations(acl)) {
|
|
337
|
+
if (agentMatches(auth, request) && originMatches(auth, request)) {
|
|
338
|
+
for (const mode of auth.modes) {
|
|
339
|
+
granted.add(mode);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
granted: isModeSatisfied(request.mode, granted),
|
|
346
|
+
modes: toAccessModes(granted),
|
|
347
|
+
effectiveAcl: acl.target,
|
|
348
|
+
};
|
|
349
|
+
}
|