UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

694 lines (639 loc) • 23.8 kB
// Copyright Inrupt Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the // Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // import type { Quad } from "@rdfjs/types"; import { getSolidDataset } from "../resource/solidDataset"; import type { IriString, WithChangeLog, Thing, WithServerResourceInfo, } from "../interfaces"; import { getSourceUrl, getResourceInfo, getSourceIri, } from "../resource/resource"; import { acl, rdf } from "../constants"; import { DataFactory, subjectToRdfJsQuads } from "../rdfjs.internal"; import { createThing, getThingAll, removeThing, setThing, } from "../thing/thing"; import { getIri, getIriAll } from "../thing/get"; import { setIri } from "../thing/set"; import { addIri } from "../thing/add"; import type { Access, AclDataset, AclRule, WithAccessibleAcl, WithAcl, WithFallbackAcl, WithResourceAcl, } from "./acl"; import { hasAccessibleAcl } from "./acl"; import { removeAll, removeIri } from "../thing/remove"; import { freeze } from "../rdf.internal"; import { internal_cloneResource } from "../resource/resource.internal"; import { isAcr } from "../acp/acp.internal"; /** * This (currently internal) function fetches the ACL indicated in the [[WithServerResourceInfo]] * attached to a resource. * * @internal * @param resourceInfo The Resource info with the ACL URL * @param options Optional parameter `options.fetch`: An alternative `fetch` function to make the HTTP request, compatible with the browser-native [fetch API](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters). */ export async function internal_fetchAcl( resourceInfo: WithServerResourceInfo, options?: { fetch?: typeof fetch }, ): Promise<WithAcl["internal_acl"]> { if (!hasAccessibleAcl(resourceInfo)) { return { resourceAcl: null, fallbackAcl: null, }; } try { const resourceAcl = await internal_fetchResourceAcl(resourceInfo, options); const acl = resourceAcl === null ? { resourceAcl: null, fallbackAcl: await internal_fetchFallbackAcl(resourceInfo, options), } : { resourceAcl, fallbackAcl: null }; return acl; } catch (e: unknown) { /* istanbul ignore else: fetchResourceAcl swallows all non-AclIsAcrErrors */ if (e instanceof AclIsAcrError) { return { resourceAcl: null, fallbackAcl: null, }; } /* istanbul ignore next: fetchResourceAcl swallows all non-AclIsAcrErrors */ throw e; } } /** @internal */ export async function internal_fetchResourceAcl( dataset: WithServerResourceInfo, options?: { fetch?: typeof fetch }, ): Promise<AclDataset | null> { if (!hasAccessibleAcl(dataset)) { return null; } try { const aclSolidDataset = await getSolidDataset( dataset.internal_resourceInfo.aclUrl, options, ); if (isAcr(aclSolidDataset)) { throw new AclIsAcrError(dataset, aclSolidDataset); } return freeze({ ...aclSolidDataset, internal_accessTo: getSourceUrl(dataset), }); } catch (e) { if (e instanceof AclIsAcrError) { throw e; } // Since a Solid server adds a `Link` header to an ACL even if that ACL does not exist, // failure to fetch the ACL is expected to happen - we just return `null` and let callers deal // with it. return null; } } /** @internal */ export async function internal_fetchFallbackAcl( resource: WithAccessibleAcl, options?: { fetch?: typeof fetch }, ): Promise<AclDataset | null> { const resourceUrl = new URL(getSourceUrl(resource)); const resourcePath = resourceUrl.pathname; // Note: we're currently assuming that the Origin is the root of the Pod. However, it is not yet // set in stone that that will always be the case. We might need to check the Container's // metadata at some point in time to check whether it is actually the root of the Pod. // See: https://github.com/solid/specification/issues/153#issuecomment-624630022 if (resourcePath === "/") { // We're already at the root, so there's no Container we can retrieve: return null; } const containerPath = internal_getContainerPath(resourcePath); const containerIri = new URL(containerPath, resourceUrl.origin).href; const containerInfo = await getResourceInfo(containerIri, options); if (!hasAccessibleAcl(containerInfo)) { // If the current user does not have access to this Container's ACL, // we cannot determine whether its ACL is the one that applies. Thus, return null: return null; } const containerAcl = await internal_fetchResourceAcl(containerInfo, options); if (containerAcl === null) { return internal_fetchFallbackAcl(containerInfo, options); } return containerAcl; } /** * Given the path to a Resource, get the URL of the Container one level up in the hierarchy. * @param resourcePath The path of the Resource of which we need to determine the Container's path. * @hidden For internal use only. */ export function internal_getContainerPath(resourcePath: string): string { const resourcePathWithoutTrailingSlash = resourcePath.substring(resourcePath.length - 1) === "/" ? resourcePath.substring(0, resourcePath.length - 1) : resourcePath; const containerPath = `${resourcePath.substring( 0, resourcePathWithoutTrailingSlash.lastIndexOf("/"), )}/`; return containerPath; } /** @internal */ export function internal_getAclRules(aclDataset: AclDataset): AclRule[] { const things = getThingAll(aclDataset); return things.filter(isAclRule); } function isAclRule(thing: Thing): thing is AclRule { return getIriAll(thing, rdf.type).includes(acl.Authorization); } /** @internal */ export function internal_getResourceAclRules(aclRules: AclRule[]): AclRule[] { return aclRules.filter(isResourceAclRule); } function isResourceAclRule(aclRule: AclRule): boolean { return getIri(aclRule, acl.accessTo) !== null; } /** @internal */ export function internal_getResourceAclRulesForResource( aclRules: AclRule[], resource: IriString, ): AclRule[] { return aclRules.filter((rule) => appliesToResource(rule, resource)); } function appliesToResource(aclRule: AclRule, resource: IriString): boolean { return getIriAll(aclRule, acl.accessTo).includes(resource); } /** @internal */ export function internal_getDefaultAclRules(aclRules: AclRule[]): AclRule[] { return aclRules.filter(isDefaultAclRule); } function isDefaultAclRule(aclRule: AclRule): boolean { return ( getIri(aclRule, acl.default) !== null || getIri(aclRule, acl.defaultForNew) !== null ); } /** @internal */ export function internal_getDefaultAclRulesForResource( aclRules: AclRule[], resource: IriString, ): AclRule[] { return aclRules.filter((rule) => isDefaultForResource(rule, resource)); } function isDefaultForResource(aclRule: AclRule, resource: IriString): boolean { return ( getIriAll(aclRule, acl.default).includes(resource) || getIriAll(aclRule, acl.defaultForNew).includes(resource) ); } /** @internal */ export function internal_getAccess(rule: AclRule): Access { const ruleAccessModes = getIriAll(rule, acl.mode); const writeAccess = ruleAccessModes.includes( internal_accessModeIriStrings.write, ); return writeAccess ? { read: ruleAccessModes.includes(internal_accessModeIriStrings.read), append: true, write: true, control: ruleAccessModes.includes( internal_accessModeIriStrings.control, ), } : { read: ruleAccessModes.includes(internal_accessModeIriStrings.read), append: ruleAccessModes.includes(internal_accessModeIriStrings.append), write: false, control: ruleAccessModes.includes( internal_accessModeIriStrings.control, ), }; } /** @internal */ export function internal_combineAccessModes(modes: Access[]): Access { return modes.reduce( (accumulator, current) => { const writeAccess = accumulator.write || current.write; return writeAccess ? { read: accumulator.read || current.read, append: true, write: true, control: accumulator.control || current.control, } : { read: accumulator.read || current.read, append: accumulator.append || current.append, write: false, control: accumulator.control || current.control, }; }, { read: false, append: false, write: false, control: false }, ); } /** @internal */ export function internal_removeEmptyAclRules<Dataset extends AclDataset>( aclDataset: Dataset, ): Dataset { const aclRules = internal_getAclRules(aclDataset); const aclRulesToRemove = aclRules.filter(isEmptyAclRule); // Is this too clever? It iterates over aclRulesToRemove, one by one removing them from aclDataset. const updatedAclDataset = aclRulesToRemove.reduce(removeThing, aclDataset); return updatedAclDataset; } function isEmptyAclRule(aclRule: AclRule): boolean { // If there are Quads in there unrelated to Access Control, // this is not an empty ACL rule that can be deleted: if ( subjectToRdfJsQuads( aclRule.predicates, DataFactory.namedNode(aclRule.url), DataFactory.defaultGraph(), ).some((quad) => !isAclQuad(quad)) ) { return false; } // If the rule does not apply to any Resource, it is no longer working: if ( getIri(aclRule, acl.accessTo) === null && getIri(aclRule, acl.default) === null && getIri(aclRule, acl.defaultForNew) === null ) { return true; } // If the rule does not specify Access Modes, it is no longer working: if (getIri(aclRule, acl.mode) === null) { return true; } // If the rule does not specify whom it applies to, it is no longer working: if ( getIri(aclRule, acl.agent) === null && getIri(aclRule, acl.agentGroup) === null && getIri(aclRule, acl.agentClass) === null ) { return true; } return false; } function isAclQuad(quad: Quad): boolean { const { predicate } = quad; const { object } = quad; if ( predicate.equals(DataFactory.namedNode(rdf.type)) && object.equals(DataFactory.namedNode(acl.Authorization)) ) { return true; } if ( predicate.equals(DataFactory.namedNode(acl.accessTo)) || predicate.equals(DataFactory.namedNode(acl.default)) || predicate.equals(DataFactory.namedNode(acl.defaultForNew)) ) { return true; } if ( predicate.equals(DataFactory.namedNode(acl.mode)) && Object.values(internal_accessModeIriStrings).some((mode) => object.equals(DataFactory.namedNode(mode)), ) ) { return true; } if ( predicate.equals(DataFactory.namedNode(acl.agent)) || predicate.equals(DataFactory.namedNode(acl.agentGroup)) || predicate.equals(DataFactory.namedNode(acl.agentClass)) ) { return true; } if (predicate.equals(DataFactory.namedNode(acl.origin))) { return true; } return false; } /** * IRIs of potential Access Modes * @internal */ export const internal_accessModeIriStrings = { read: "http://www.w3.org/ns/auth/acl#Read", append: "http://www.w3.org/ns/auth/acl#Append", write: "http://www.w3.org/ns/auth/acl#Write", control: "http://www.w3.org/ns/auth/acl#Control", } as const; /** @internal * This function finds, among a set of ACL rules, the ones granting access to a given entity (the target) * and identifying it with a specific property (`acl:agent` or `acl:agentGroup`). * @param aclRules The set of rules to filter * @param targetIri The IRI of the target * @param targetType The property linking the rule to the target */ export function internal_getAclRulesForIri( aclRules: AclRule[], targetIri: IriString, targetType: typeof acl.agent | typeof acl.agentGroup, ): AclRule[] { return aclRules.filter((rule) => getIriAll(rule, targetType).includes(targetIri), ); } /** @internal * This function transforms a given set of rules into a map associating the IRIs * of the entities to which permissions are granted by these rules, and the permissions * granted to them. Additionally, it filters these entities based on the predicate * that refers to them in the rule. */ export function internal_getAccessByIri( aclRules: AclRule[], targetType: typeof acl.agent | typeof acl.agentGroup, ): Record<IriString, Access> { const targetIriAccess: Record<IriString, Access> = {}; aclRules.forEach((rule) => { const ruleTargetIri = getIriAll(rule, targetType); const access = internal_getAccess(rule); // A rule might apply to multiple agents. If multiple rules apply to the same agent, the Access // Modes granted by those rules should be combined: ruleTargetIri.forEach((targetIri) => { targetIriAccess[targetIri] = typeof targetIriAccess[targetIri] === "undefined" ? access : internal_combineAccessModes([targetIriAccess[targetIri], access]); }); }); return targetIriAccess; } /** * Initialises a new ACL Rule that grants some access - but does not yet specify to whom. * * @hidden This is an internal utility function that should not be used directly by downstreams. * @param access Access mode that this Rule will grant */ export function internal_initialiseAclRule(access: Access): AclRule { let newRule = createThing(); newRule = setIri(newRule, rdf.type, acl.Authorization); if (access.read) { newRule = addIri(newRule, acl.mode, internal_accessModeIriStrings.read); } if (access.append && !access.write) { newRule = addIri(newRule, acl.mode, internal_accessModeIriStrings.append); } if (access.write) { newRule = addIri(newRule, acl.mode, internal_accessModeIriStrings.write); } if (access.control) { newRule = addIri(newRule, acl.mode, internal_accessModeIriStrings.control); } return newRule; } /** * Creates a new ACL Rule with the same ACL values as the input ACL Rule, but having a different IRI. * * Note that non-ACL values will not be copied over. * * @hidden This is an internal utility function that should not be used directly by downstreams. * @param sourceRule ACL rule to duplicate. */ export function internal_duplicateAclRule(sourceRule: AclRule): AclRule { let targetRule = createThing(); targetRule = setIri(targetRule, rdf.type, acl.Authorization); function copyIris( inputRule: typeof sourceRule, outputRule: typeof targetRule, predicate: IriString, ) { return getIriAll(inputRule, predicate).reduce( (outputRule, iriTarget) => addIri(outputRule, predicate, iriTarget), outputRule, ); } targetRule = copyIris(sourceRule, targetRule, acl.accessTo); targetRule = copyIris(sourceRule, targetRule, acl.default); targetRule = copyIris(sourceRule, targetRule, acl.defaultForNew); targetRule = copyIris(sourceRule, targetRule, acl.agent); targetRule = copyIris(sourceRule, targetRule, acl.agentGroup); targetRule = copyIris(sourceRule, targetRule, acl.agentClass); targetRule = copyIris(sourceRule, targetRule, acl.origin); targetRule = copyIris(sourceRule, targetRule, acl.mode); return targetRule; } /** * Attach an ACL dataset to a Resource. * * @hidden This is an internal utility function that should not be used directly by downstreams. * @param resource The Resource to which an ACL is being attached * @param acl The ACL being attached to the Resource */ export function internal_setAcl<ResourceExt extends WithServerResourceInfo>( resource: ResourceExt, acl: WithResourceAcl["internal_acl"], ): ResourceExt & WithResourceAcl; export function internal_setAcl<ResourceExt extends WithServerResourceInfo>( resource: ResourceExt, acl: WithFallbackAcl["internal_acl"], ): ResourceExt & WithFallbackAcl; export function internal_setAcl<ResourceExt extends WithServerResourceInfo>( resource: ResourceExt, acl: WithAcl["internal_acl"], ): ResourceExt & WithAcl; export function internal_setAcl<ResourceExt extends WithServerResourceInfo>( resource: ResourceExt, acl: WithAcl["internal_acl"], ): ResourceExt & WithAcl { return Object.assign(internal_cloneResource(resource), { internal_acl: acl }); } const supportedActorPredicates = [ acl.agent, acl.agentClass, acl.agentGroup, acl.origin, ]; /** * Union type of all relations defined in `knownActorRelations`. * * When the ACP spec evolves to support additional relations of Rules to Actors, * adding those relations to `knownActorRelations` will cause TypeScript to warn * us everywhere to update everywhere the ActorRelation type is used and that * needs additional work to handle it. */ type SupportedActorPredicate = typeof supportedActorPredicates extends Array<infer E> ? E : never; /** * Given an ACL Rule, returns two new ACL Rules that cover all the input Rule's use cases, * except for giving the given Actor access to the given Resource. * * @param rule The ACL Rule that should no longer apply for a given Actor to a given Resource. * @param actor The Actor that should be removed from the Rule for the given Resource. * @param resourceIri The Resource to which the Rule should no longer apply for the given Actor. * @returns A tuple with the original ACL Rule without the given Actor, and a new ACL Rule for the given Actor for the remaining Resources, respectively. */ function internal_removeActorFromRule( rule: AclRule, actor: IriString, actorPredicate: SupportedActorPredicate, resourceIri: IriString, ruleType: "resource" | "default", ): [AclRule, AclRule] { // If the existing Rule does not apply to the given Actor, we don't need to split up. // Without this check, we'd be creating a new rule for the given Actor (ruleForOtherTargets) // that would give it access it does not currently have: if (!getIriAll(rule, actorPredicate).includes(actor)) { const emptyRule = internal_initialiseAclRule({ read: false, append: false, write: false, control: false, }); return [rule, emptyRule]; } // The existing rule will keep applying to Actors other than the given one: const ruleWithoutActor = removeIri(rule, actorPredicate, actor); // The actor might have been given other access in the existing rule, so duplicate it... let ruleForOtherTargets = internal_duplicateAclRule(rule); // ...but remove access to the original Resource... ruleForOtherTargets = removeIri( ruleForOtherTargets, ruleType === "resource" ? acl.accessTo : acl.default, resourceIri, ); // Prevents the legacy predicate 'acl:defaultForNew' to lead to privilege escalation if (ruleType === "default") { ruleForOtherTargets = removeIri( ruleForOtherTargets, acl.defaultForNew, resourceIri, ); } // ...and only apply the new Rule to the given Actor (because the existing Rule covers the others): ruleForOtherTargets = setIri(ruleForOtherTargets, actorPredicate, actor); supportedActorPredicates .filter((predicate) => predicate !== actorPredicate) .forEach((predicate) => { ruleForOtherTargets = removeAll(ruleForOtherTargets, predicate); }); return [ruleWithoutActor, ruleForOtherTargets]; } /** * ```{note} * This function is still experimental and subject to change, even in a non-major release. * ``` * Modifies the resource ACL (Access Control List) to set the Access Modes for the given Agent. * Specifically, the function returns a new resource ACL initialised with the given ACL and * new rules for the Actor's access. * * If rules for Actor's access already exist in the given ACL, in the returned ACL, * they are replaced by the new rules. * * This function does not modify: * * - Access Modes granted indirectly to Actors through other ACL rules, e.g., public or group-specific permissions. * - Access Modes granted to Actors for the child Resources if the associated Resource is a Container. * - The original ACL. * * @param aclDataset The SolidDataset that contains Access-Control List rules. * @param actor The Actor to grant specific Access Modes. * @param access The Access Modes to grant to the Actor for the Resource. * @returns A new resource ACL initialised with the given `aclDataset` and `access` for the `agent`. */ export function internal_setActorAccess( aclDataset: AclDataset, access: Access, actorPredicate: SupportedActorPredicate, accessType: "default" | "resource", actor: IriString, ): AclDataset & WithChangeLog { // First make sure that none of the pre-existing rules in the given ACL SolidDataset // give the Agent access to the Resource: let filteredAcl = aclDataset; getThingAll(aclDataset).forEach((aclRule) => { // Obtain both the Rule that no longer includes the given Actor, // and a new Rule that includes all ACL Quads // that do not pertain to the given Actor-Resource combination. // Note that usually, the latter will no longer include any meaningful statements; // we'll clean them up afterwards. const [filteredRule, remainingRule] = internal_removeActorFromRule( aclRule, actor, actorPredicate, aclDataset.internal_accessTo, accessType, ); filteredAcl = setThing(filteredAcl, filteredRule); filteredAcl = setThing(filteredAcl, remainingRule); }); // Create a new Rule that only grants the given Actor the given Access Modes: let newRule = internal_initialiseAclRule(access); newRule = setIri( newRule, accessType === "resource" ? acl.accessTo : acl.default, aclDataset.internal_accessTo, ); newRule = setIri(newRule, actorPredicate, actor); const updatedAcl = setThing(filteredAcl, newRule); // Remove any remaining Rules that do not contain any meaningful statements: return internal_removeEmptyAclRules(updatedAcl); } export function internal_setResourceAcl< T extends WithServerResourceInfo & WithAcl, >(resource: T, acl: AclDataset): T & WithResourceAcl { const newAcl: WithResourceAcl["internal_acl"] = { resourceAcl: acl, fallbackAcl: null, }; return internal_setAcl(resource, newAcl); } export function internal_getResourceAcl( resource: WithServerResourceInfo & WithResourceAcl, ): AclDataset { return resource.internal_acl.resourceAcl; } /** * This error indicates that, if we're following a Link with rel="acl", * it does not result in a WAC ACL, but in an ACP ACR. */ class AclIsAcrError extends Error { constructor( sourceResource: WithServerResourceInfo, aclResource: WithServerResourceInfo, ) { super( `[${getSourceIri( sourceResource, )}] is governed by Access Control Policies in [${getSourceIri( aclResource, )}] rather than by Web Access Control.`, ); } }