diff --git a/config/ldp/authorization/authorizers/access-checkers/agentGroup.json b/config/ldp/authorization/authorizers/access-checkers/agentGroup.json new file mode 100644 index 0000000000..fdc402bb48 --- /dev/null +++ b/config/ldp/authorization/authorizers/access-checkers/agentGroup.json @@ -0,0 +1,10 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", + "@graph": [ + { + "@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 7a71a7b541..3e23fad65f 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/agentClass.json" + "files-scs:config/ldp/authorization/authorizers/access-checkers/agentClass.json", + "files-scs:config/ldp/authorization/authorizers/access-checkers/agentGroup.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/WebAclAuthorizer.ts b/src/authorization/WebAclAuthorizer.ts index cc8db59071..f41b146562 100644 --- a/src/authorization/WebAclAuthorizer.ts +++ b/src/authorization/WebAclAuthorizer.ts @@ -31,8 +31,9 @@ const modesMap: Record = { /** * Handles most web access control predicates such as - * `acl:mode`, `acl:agentClass`, `acl:agent`, `acl:default` and `acl:accessTo`. - * Does not support `acl:agentGroup`, `acl:origin` and `acl:trustedApp` yet. + * `acl:mode`, `acl:agentClass`, `acl:agent`, `acl:default`, + * `acl:accessTo` and `acl:agentGroup`. + * Does not support `acl:origin` and `acl:trustedApp` yet. */ export class WebAclAuthorizer extends Authorizer { protected readonly logger = getLoggerFor(this); diff --git a/src/authorization/access-checkers/AgentGroupAccessChecker.ts b/src/authorization/access-checkers/AgentGroupAccessChecker.ts new file mode 100644 index 0000000000..66074ebe5f --- /dev/null +++ b/src/authorization/access-checkers/AgentGroupAccessChecker.ts @@ -0,0 +1,49 @@ +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. + */ +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 6a9b7ce8ff..3994f8c37c 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 a27bde91e1..060820e22e 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 0000000000..826e02d60d --- /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); + }); +});