From 401923b792b6d1dcd51b6645cf414274560fd38d Mon Sep 17 00:00:00 2001 From: Simone Persiani Date: Tue, 17 Aug 2021 16:56:09 +0200 Subject: [PATCH] feat: Add support for agentGroup ACL rules Co-Authored-By: Ludovico Granata --- .../access-checkers/agent-group.json | 11 ++++ config/ldp/authorization/authorizers/acl.json | 6 ++- .../AgentGroupAccessChecker.ts | 50 +++++++++++++++++++ src/index.ts | 1 + src/util/Vocabularies.ts | 5 ++ .../AgentGroupAccessChecker.test.ts | 50 +++++++++++++++++++ 6 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 config/ldp/authorization/authorizers/access-checkers/agent-group.json create mode 100644 src/authorization/access-checkers/AgentGroupAccessChecker.ts create mode 100644 test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts diff --git a/config/ldp/authorization/authorizers/access-checkers/agent-group.json b/config/ldp/authorization/authorizers/access-checkers/agent-group.json new file mode 100644 index 000000000..761bbcab1 --- /dev/null +++ b/config/ldp/authorization/authorizers/access-checkers/agent-group.json @@ -0,0 +1,11 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", + "@graph": [ + { + "comment": "Checks if the agent belongs to a group that has access.", + "@id": "urn:solid-server:default:AgentGroupAccessChecker", + "@type": "AgentGroupAccessChecker", + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" } + } + ] +} diff --git a/config/ldp/authorization/authorizers/acl.json b/config/ldp/authorization/authorizers/acl.json index 35a9c3170..36d88f73e 100644 --- a/config/ldp/authorization/authorizers/acl.json +++ b/config/ldp/authorization/authorizers/acl.json @@ -2,7 +2,8 @@ "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "import": [ "files-scs:config/ldp/authorization/authorizers/access-checkers/agent.json", - "files-scs:config/ldp/authorization/authorizers/access-checkers/agent-class.json" + "files-scs:config/ldp/authorization/authorizers/access-checkers/agent-class.json", + "files-scs:config/ldp/authorization/authorizers/access-checkers/agent-group.json" ], "@graph": [ { @@ -21,7 +22,8 @@ "@type": "BooleanHandler", "handlers": [ { "@id": "urn:solid-server:default:AgentAccessChecker" }, - { "@id": "urn:solid-server:default:AgentClassAccessChecker" } + { "@id": "urn:solid-server:default:AgentClassAccessChecker" }, + { "@id": "urn:solid-server:default:AgentGroupAccessChecker" } ] } } diff --git a/src/authorization/access-checkers/AgentGroupAccessChecker.ts b/src/authorization/access-checkers/AgentGroupAccessChecker.ts new file mode 100644 index 000000000..4170e69c1 --- /dev/null +++ b/src/authorization/access-checkers/AgentGroupAccessChecker.ts @@ -0,0 +1,50 @@ +import type { Term } from 'n3'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; +import { fetchDataset } from '../../util/FetchUtil'; +import { promiseSome } from '../../util/PromiseUtil'; +import { readableToQuads } from '../../util/StreamUtil'; +import { ACL, VCARD } from '../../util/Vocabularies'; +import type { AccessCheckerArgs } from './AccessChecker'; +import { AccessChecker } from './AccessChecker'; + +/** + * Checks if the given WebID belongs to a group that has access. + * Implements the behaviour of groups from the WAC specification. + */ +export class AgentGroupAccessChecker extends AccessChecker { + private readonly converter: RepresentationConverter; + + public constructor(converter: RepresentationConverter) { + super(); + this.converter = converter; + } + + public async handle({ acl, rule, credentials }: AccessCheckerArgs): Promise { + if (typeof credentials.webId === 'string') { + const { webId } = credentials; + const groups = acl.getObjects(rule, ACL.terms.agentGroup, null); + + return await promiseSome(groups.map(async(group: Term): Promise => + this.isMemberOfGroup(webId, group))); + } + return false; + } + + /** + * Checks if the given agent is member of a given vCard group. + * @param webId - WebID of the agent that needs access. + * @param group - URL of the vCard group that needs to be checked. + * + * @returns If the agent is member of the given vCard group. + */ + private async isMemberOfGroup(webId: string, group: Term): Promise { + const groupDocument: ResourceIdentifier = { path: /^[^#]*/u.exec(group.value)![0] }; + + // Fetch the required vCard group file + const dataset = await fetchDataset(groupDocument.path, this.converter); + + const quads = await readableToQuads(dataset.data); + return quads.countQuads(group, VCARD.terms.hasMember, webId, null) !== 0; + } +} diff --git a/src/index.ts b/src/index.ts index 6a9b7ce8f..3994f8c37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export * from './authorization/WebAclAuthorizer'; export * from './authorization/access-checkers/AccessChecker'; export * from './authorization/access-checkers/AgentAccessChecker'; export * from './authorization/access-checkers/AgentClassAccessChecker'; +export * from './authorization/access-checkers/AgentGroupAccessChecker'; // Identity/Configuration export * from './identity/configuration/IdentityProviderFactory'; diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index a27bde91e..060820e22 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -59,6 +59,7 @@ export const ACL = createUriAndTermNamespace('http://www.w3.org/ns/auth/acl#', 'accessTo', 'agent', 'agentClass', + 'agentGroup', 'AuthenticatedAgent', 'Authorization', 'default', @@ -144,6 +145,10 @@ export const VANN = createUriAndTermNamespace('http://purl.org/vocab/vann/', 'preferredNamespacePrefix', ); +export const VCARD = createUriAndTermNamespace('http://www.w3.org/2006/vcard/ns#', + 'hasMember', +); + export const XSD = createUriAndTermNamespace('http://www.w3.org/2001/XMLSchema#', 'dateTime', 'integer', diff --git a/test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts b/test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts new file mode 100644 index 000000000..826e02d60 --- /dev/null +++ b/test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts @@ -0,0 +1,50 @@ +import { DataFactory, Store } from 'n3'; +import type { AccessCheckerArgs } from '../../../../src/authorization/access-checkers/AccessChecker'; +import { AgentGroupAccessChecker } from '../../../../src/authorization/access-checkers/AgentGroupAccessChecker'; +import { BasicRepresentation } from '../../../../src/ldp/representation/BasicRepresentation'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; +import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; +import * as fetchUtil from '../../../../src/util/FetchUtil'; +import { ACL, VCARD } from '../../../../src/util/Vocabularies'; +const { namedNode, quad } = DataFactory; + +describe('An AgentGroupAccessChecker', (): void => { + const webId = 'http://test.com/alice/profile/card#me'; + const groupId = 'http://test.com/group'; + const acl = new Store(); + acl.addQuad(namedNode('groupMatch'), ACL.terms.agentGroup, namedNode(groupId)); + acl.addQuad(namedNode('noMatch'), ACL.terms.agentGroup, namedNode('badGroup')); + let fetchMock: jest.SpyInstance; + let representation: Representation; + const converter: RepresentationConverter = {} as any; + let checker: AgentGroupAccessChecker; + + beforeEach(async(): Promise => { + const groupQuads = [ quad(namedNode(groupId), VCARD.terms.hasMember, namedNode(webId)) ]; + representation = new BasicRepresentation(groupQuads, INTERNAL_QUADS, false); + fetchMock = jest.spyOn(fetchUtil, 'fetchDataset'); + fetchMock.mockResolvedValue(representation); + + checker = new AgentGroupAccessChecker(converter); + }); + + it('can handle all requests.', async(): Promise => { + await expect(checker.canHandle(null as any)).resolves.toBeUndefined(); + }); + + it('returns true if the WebID is a valid group member.', async(): Promise => { + const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credentials: { webId }}; + await expect(checker.handle(input)).resolves.toBe(true); + }); + + it('returns false if the WebID is not a valid group member.', async(): Promise => { + const input: AccessCheckerArgs = { acl, rule: namedNode('noMatch'), credentials: { webId }}; + await expect(checker.handle(input)).resolves.toBe(false); + }); + + it('returns false if there are no WebID credentials.', async(): Promise => { + const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credentials: {}}; + await expect(checker.handle(input)).resolves.toBe(false); + }); +});