Skip to content

Commit 9a29cc2

Browse files
committed
fix: Extract correct access modes from request
1 parent 0e4d012 commit 9a29cc2

9 files changed

+197
-71
lines changed

RELEASE_NOTES.md

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ These changes are relevant if you wrote custom modules for the server that depen
2323
- `PermissionReader`s take an additional `modes` parameter as input.
2424
- The `ResourceStore` function `resourceExists` has been renamed to `hasResource`
2525
and has been moved to a separate `ResourceSet` interface.
26+
- Several `ModesExtractor`s now take a `ResourceSet` as constructor parameter.
2627

2728
## v3.0.0
2829
### New features

config/ldp/modes/default.json

+10-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
},
1313
{
1414
"comment": "Extract access modes based on the HTTP method.",
15-
"@type": "MethodModesExtractor"
15+
"@type": "MethodModesExtractor",
16+
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }
1617
},
1718
{
1819
"@type": "StaticThrowHandler",
@@ -27,8 +28,14 @@
2728
"source": {
2829
"@type": "WaterfallHandler",
2930
"handlers": [
30-
{ "@type": "N3PatchModesExtractor" },
31-
{ "@type": "SparqlUpdateModesExtractor" },
31+
{
32+
"@type": "N3PatchModesExtractor",
33+
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }
34+
},
35+
{
36+
"@type": "SparqlUpdateModesExtractor",
37+
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }
38+
},
3239
{
3340
"@type": "StaticThrowHandler",
3441
"error": { "@type": "UnsupportedMediaTypeHttpError" }

src/authorization/WebAclReader.ts

+13-15
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Quad, Term } from 'n3';
22
import { Store } from 'n3';
3-
import { CredentialGroup } from '../authentication/Credentials';
43
import type { Credential, CredentialSet } from '../authentication/Credentials';
4+
import { CredentialGroup } from '../authentication/Credentials';
55
import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy';
66
import type { Representation } from '../http/representation/Representation';
77
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
@@ -20,14 +20,15 @@ import type { PermissionReaderInput } from './PermissionReader';
2020
import { PermissionReader } from './PermissionReader';
2121
import type { AclPermission } from './permissions/AclPermission';
2222
import { AclMode } from './permissions/AclPermission';
23-
import { AccessMode } from './permissions/Permissions';
2423
import type { PermissionSet } from './permissions/Permissions';
24+
import { AccessMode } from './permissions/Permissions';
2525

26-
const modesMap: Record<string, keyof AclPermission> = {
27-
[ACL.Read]: AccessMode.read,
28-
[ACL.Write]: AccessMode.write,
29-
[ACL.Append]: AccessMode.append,
30-
[ACL.Control]: AclMode.control,
26+
// Maps ACL modes to their associated general modes.
27+
const modesMap: Record<string, Readonly<(keyof AclPermission)[]>> = {
28+
[ACL.Read]: [ AccessMode.read ],
29+
[ACL.Write]: [ AccessMode.append, AccessMode.write, AccessMode.create, AccessMode.delete ],
30+
[ACL.Append]: [ AccessMode.append ],
31+
[ACL.Control]: [ AclMode.control ],
3132
} as const;
3233

3334
/**
@@ -106,19 +107,16 @@ export class WebAclReader extends PermissionReader {
106107
if (hasAccess) {
107108
// Set all allowed modes to true
108109
const modes = acl.getObjects(rule, ACL.mode, null);
109-
for (const { value: mode } of modes) {
110-
if (mode in modesMap) {
111-
aclPermissions[modesMap[mode]] = true;
110+
for (const { value: aclMode } of modes) {
111+
if (aclMode in modesMap) {
112+
for (const mode of modesMap[aclMode]) {
113+
aclPermissions[mode] = true;
114+
}
112115
}
113116
}
114117
}
115118
}
116119

117-
if (aclPermissions.write) {
118-
// Write permission implies Append permission
119-
aclPermissions.append = true;
120-
}
121-
122120
return aclPermissions;
123121
}
124122

Original file line numberDiff line numberDiff line change
@@ -1,37 +1,63 @@
11
import type { Operation } from '../../http/Operation';
2+
import type { ResourceSet } from '../../storage/ResourceSet';
23
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
4+
import { isContainerIdentifier } from '../../util/PathUtil';
35
import { ModesExtractor } from './ModesExtractor';
46
import { AccessMode } from './Permissions';
57

68
const READ_METHODS = new Set([ 'GET', 'HEAD' ]);
7-
const WRITE_METHODS = new Set([ 'PUT', 'DELETE' ]);
8-
const APPEND_METHODS = new Set([ 'POST' ]);
9-
const SUPPORTED_METHODS = new Set([ ...READ_METHODS, ...WRITE_METHODS, ...APPEND_METHODS ]);
9+
const SUPPORTED_METHODS = new Set([ ...READ_METHODS, 'PUT', 'POST', 'DELETE' ]);
1010

1111
/**
1212
* Generates permissions for the base set of methods that always require the same permissions.
1313
* Specifically: GET, HEAD, POST, PUT and DELETE.
1414
*/
1515
export class MethodModesExtractor extends ModesExtractor {
16+
private readonly resourceSet: ResourceSet;
17+
18+
/**
19+
* Certain permissions depend on the existence of the target resource.
20+
* The provided {@link ResourceSet} will be used for that.
21+
* @param resourceSet - {@link ResourceSet} that can verify the target resource existence.
22+
*/
23+
public constructor(resourceSet: ResourceSet) {
24+
super();
25+
this.resourceSet = resourceSet;
26+
}
27+
1628
public async canHandle({ method }: Operation): Promise<void> {
1729
if (!SUPPORTED_METHODS.has(method)) {
1830
throw new NotImplementedHttpError(`Cannot determine permissions of ${method}`);
1931
}
2032
}
2133

22-
public async handle({ method }: Operation): Promise<Set<AccessMode>> {
23-
const result = new Set<AccessMode>();
34+
public async handle({ method, target }: Operation): Promise<Set<AccessMode>> {
35+
const modes = new Set<AccessMode>();
36+
// Reading requires Read permissions on the resource
2437
if (READ_METHODS.has(method)) {
25-
result.add(AccessMode.read);
38+
modes.add(AccessMode.read);
39+
}
40+
// Setting a resource's representation requires Write permissions
41+
if (method === 'PUT') {
42+
modes.add(AccessMode.write);
43+
// …and, if the resource does not exist yet, Create permissions are required as well
44+
if (!await this.resourceSet.hasResource(target)) {
45+
modes.add(AccessMode.create);
46+
}
47+
}
48+
// Creating a new resource in a container requires Append access to that container
49+
if (method === 'POST') {
50+
modes.add(AccessMode.append);
2651
}
27-
if (WRITE_METHODS.has(method)) {
28-
result.add(AccessMode.write);
29-
result.add(AccessMode.append);
30-
result.add(AccessMode.create);
31-
result.add(AccessMode.delete);
32-
} else if (APPEND_METHODS.has(method)) {
33-
result.add(AccessMode.append);
52+
// Deleting a resource requires Delete access
53+
if (method === 'DELETE') {
54+
modes.add(AccessMode.delete);
55+
// …and, if the target is a container, Read permissions are required as well
56+
// as this exposes if a container is empty or not
57+
if (isContainerIdentifier(target)) {
58+
modes.add(AccessMode.read);
59+
}
3460
}
35-
return result;
61+
return modes;
3662
}
3763
}

src/authorization/permissions/N3PatchModesExtractor.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Operation } from '../../http/Operation';
22
import type { N3Patch } from '../../http/representation/N3Patch';
33
import { isN3Patch } from '../../http/representation/N3Patch';
4+
import type { ResourceSet } from '../../storage/ResourceSet';
45
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
56
import { ModesExtractor } from './ModesExtractor';
67
import { AccessMode } from './Permissions';
@@ -14,13 +15,25 @@ import { AccessMode } from './Permissions';
1415
* https://solid.github.io/specification/protocol#n3-patch
1516
*/
1617
export class N3PatchModesExtractor extends ModesExtractor {
18+
private readonly resourceSet: ResourceSet;
19+
20+
/**
21+
* Certain permissions depend on the existence of the target resource.
22+
* The provided {@link ResourceSet} will be used for that.
23+
* @param resourceSet - {@link ResourceSet} that can verify the target resource existence.
24+
*/
25+
public constructor(resourceSet: ResourceSet) {
26+
super();
27+
this.resourceSet = resourceSet;
28+
}
29+
1730
public async canHandle({ body }: Operation): Promise<void> {
1831
if (!isN3Patch(body)) {
1932
throw new NotImplementedHttpError('Can only determine permissions of N3 Patch documents.');
2033
}
2134
}
2235

23-
public async handle({ body }: Operation): Promise<Set<AccessMode>> {
36+
public async handle({ body, target }: Operation): Promise<Set<AccessMode>> {
2437
const { deletes, inserts, conditions } = body as N3Patch;
2538

2639
const accessModes = new Set<AccessMode>();
@@ -32,6 +45,9 @@ export class N3PatchModesExtractor extends ModesExtractor {
3245
// When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation.
3346
if (inserts.length > 0) {
3447
accessModes.add(AccessMode.append);
48+
if (!await this.resourceSet.hasResource(target)) {
49+
accessModes.add(AccessMode.create);
50+
}
3551
}
3652
// When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation.
3753
if (deletes.length > 0) {

src/authorization/permissions/SparqlUpdateModesExtractor.ts

+46-24
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Algebra } from 'sparqlalgebrajs';
22
import type { Operation } from '../../http/Operation';
33
import type { Representation } from '../../http/representation/Representation';
44
import type { SparqlUpdatePatch } from '../../http/representation/SparqlUpdatePatch';
5+
import type { ResourceSet } from '../../storage/ResourceSet';
56
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
67
import { ModesExtractor } from './ModesExtractor';
78
import { AccessMode } from './Permissions';
@@ -12,6 +13,18 @@ import { AccessMode } from './Permissions';
1213
* while DELETEs require write permissions as well.
1314
*/
1415
export class SparqlUpdateModesExtractor extends ModesExtractor {
16+
private readonly resourceSet: ResourceSet;
17+
18+
/**
19+
* Certain permissions depend on the existence of the target resource.
20+
* The provided {@link ResourceSet} will be used for that.
21+
* @param resourceSet - {@link ResourceSet} that can verify the target resource existence.
22+
*/
23+
public constructor(resourceSet: ResourceSet) {
24+
super();
25+
this.resourceSet = resourceSet;
26+
}
27+
1528
public async canHandle({ body }: Operation): Promise<void> {
1629
if (!this.isSparql(body)) {
1730
throw new NotImplementedHttpError('Cannot determine permissions of non-SPARQL patches.');
@@ -21,21 +34,31 @@ export class SparqlUpdateModesExtractor extends ModesExtractor {
2134
}
2235
}
2336

24-
public async handle({ body }: Operation): Promise<Set<AccessMode>> {
37+
public async handle({ body, target }: Operation): Promise<Set<AccessMode>> {
2538
// Verified in `canHandle` call
2639
const update = (body as SparqlUpdatePatch).algebra as Algebra.DeleteInsert;
27-
const result = new Set<AccessMode>();
40+
const modes = new Set<AccessMode>();
2841

29-
// Since `append` is a specific type of write, it is true if `write` is true.
30-
if (this.needsWrite(update)) {
31-
result.add(AccessMode.write);
32-
result.add(AccessMode.append);
33-
result.add(AccessMode.create);
34-
result.add(AccessMode.delete);
35-
} else if (this.needsAppend(update)) {
36-
result.add(AccessMode.append);
42+
if (this.isNop(update)) {
43+
return modes;
3744
}
38-
return result;
45+
46+
// Access modes inspired by the requirements on N3 Patch requests
47+
if (this.hasConditions(update)) {
48+
modes.add(AccessMode.read);
49+
}
50+
if (this.hasInserts(update)) {
51+
modes.add(AccessMode.append);
52+
if (!await this.resourceSet.hasResource(target)) {
53+
modes.add(AccessMode.create);
54+
}
55+
}
56+
if (this.hasDeletes(update)) {
57+
modes.add(AccessMode.read);
58+
modes.add(AccessMode.write);
59+
}
60+
61+
return modes;
3962
}
4063

4164
private isSparql(data: Representation): data is SparqlUpdatePatch {
@@ -52,33 +75,32 @@ export class SparqlUpdateModesExtractor extends ModesExtractor {
5275
return false;
5376
}
5477

55-
private isDeleteInsert(op: Algebra.Update): op is Algebra.DeleteInsert {
78+
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
5679
return op.type === Algebra.types.DELETE_INSERT;
5780
}
5881

59-
private isNop(op: Algebra.Update): op is Algebra.Nop {
82+
private isNop(op: Algebra.Operation): op is Algebra.Nop {
6083
return op.type === Algebra.types.NOP;
6184
}
6285

63-
private needsAppend(update: Algebra.Update): boolean {
64-
if (this.isNop(update)) {
65-
return false;
86+
private hasConditions(update: Algebra.Update): boolean {
87+
if (this.isDeleteInsert(update)) {
88+
return Boolean(update.where && !this.isNop(update.where));
6689
}
90+
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.hasConditions(op));
91+
}
92+
93+
private hasInserts(update: Algebra.Update): boolean {
6794
if (this.isDeleteInsert(update)) {
6895
return Boolean(update.insert && update.insert.length > 0);
6996
}
70-
71-
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsAppend(op));
97+
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.hasInserts(op));
7298
}
7399

74-
private needsWrite(update: Algebra.Update): boolean {
75-
if (this.isNop(update)) {
76-
return false;
77-
}
100+
private hasDeletes(update: Algebra.Update): boolean {
78101
if (this.isDeleteInsert(update)) {
79102
return Boolean(update.delete && update.delete.length > 0);
80103
}
81-
82-
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsWrite(op));
104+
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.hasDeletes(op));
83105
}
84106
}

test/unit/authorization/permissions/MethodModesExtractor.test.ts

+25-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { MethodModesExtractor } from '../../../../src/authorization/permissions/MethodModesExtractor';
22
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
33
import type { Operation } from '../../../../src/http/Operation';
4+
import type { ResourceSet } from '../../../../src/storage/ResourceSet';
45
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
56

67
describe('A MethodModesExtractor', (): void => {
7-
const extractor = new MethodModesExtractor();
8+
let resourceSet: jest.Mocked<ResourceSet>;
9+
let extractor: MethodModesExtractor;
10+
11+
beforeEach(async(): Promise<void> => {
12+
resourceSet = {
13+
hasResource: jest.fn().mockResolvedValue(true),
14+
};
15+
extractor = new MethodModesExtractor(resourceSet);
16+
});
817

918
it('can handle HEAD/GET/POST/PUT/DELETE.', async(): Promise<void> => {
1019
await expect(extractor.canHandle({ method: 'HEAD' } as Operation)).resolves.toBeUndefined();
@@ -29,11 +38,22 @@ describe('A MethodModesExtractor', (): void => {
2938

3039
it('requires write for PUT operations.', async(): Promise<void> => {
3140
await expect(extractor.handle({ method: 'PUT' } as Operation))
32-
.resolves.toEqual(new Set([ AccessMode.append, AccessMode.write, AccessMode.create, AccessMode.delete ]));
41+
.resolves.toEqual(new Set([ AccessMode.write ]));
42+
});
43+
44+
it('requires create for PUT operations if the target does not exist.', async(): Promise<void> => {
45+
resourceSet.hasResource.mockResolvedValueOnce(false);
46+
await expect(extractor.handle({ method: 'PUT' } as Operation))
47+
.resolves.toEqual(new Set([ AccessMode.write, AccessMode.create ]));
48+
});
49+
50+
it('requires delete for DELETE operations.', async(): Promise<void> => {
51+
await expect(extractor.handle({ method: 'DELETE', target: { path: 'http://example.com/foo' }} as Operation))
52+
.resolves.toEqual(new Set([ AccessMode.delete ]));
3353
});
3454

35-
it('requires write for DELETE operations.', async(): Promise<void> => {
36-
await expect(extractor.handle({ method: 'DELETE' } as Operation))
37-
.resolves.toEqual(new Set([ AccessMode.append, AccessMode.write, AccessMode.create, AccessMode.delete ]));
55+
it('also requires read for DELETE operations on containers.', async(): Promise<void> => {
56+
await expect(extractor.handle({ method: 'DELETE', target: { path: 'http://example.com/foo/' }} as Operation))
57+
.resolves.toEqual(new Set([ AccessMode.delete, AccessMode.read ]));
3858
});
3959
});

0 commit comments

Comments
 (0)