feat: Add support for pod owners

This commit is contained in:
Joachim Van Herwegen
2023-09-27 10:14:43 +02:00
parent 4230db5038
commit cd07338ce7
22 changed files with 821 additions and 77 deletions

View File

@@ -1,7 +1,6 @@
import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import type { PodStore } from '../identity/interaction/pod/util/PodStore';
import type { WebIdStore } from '../identity/interaction/webid/util/WebIdStore';
import { getLoggerFor } from '../logging/LogUtil';
import type { StorageLocationStrategy } from '../server/description/StorageLocationStrategy';
import { filter } from '../util/IterableUtil';
@@ -12,21 +11,19 @@ import type { AclPermissionSet } from './permissions/AclPermissionSet';
import type { PermissionMap } from './permissions/Permissions';
/**
* Allows control access if the request is being made by the owner of the pod containing the resource.
* Allows control access if the request is being made by an owner of the pod containing the resource.
*/
export class OwnerPermissionReader extends PermissionReader {
protected readonly logger = getLoggerFor(this);
private readonly podStore: PodStore;
private readonly webIdStore: WebIdStore;
private readonly authStrategy: AuxiliaryIdentifierStrategy;
private readonly storageStrategy: StorageLocationStrategy;
public constructor(podStore: PodStore, webIdStore: WebIdStore, authStrategy: AuxiliaryIdentifierStrategy,
public constructor(podStore: PodStore, authStrategy: AuxiliaryIdentifierStrategy,
storageStrategy: StorageLocationStrategy) {
super();
this.podStore = podStore;
this.webIdStore = webIdStore;
this.authStrategy = authStrategy;
this.storageStrategy = storageStrategy;
}
@@ -95,14 +92,19 @@ export class OwnerPermissionReader extends PermissionReader {
protected async findOwners(pods: string[]): Promise<Record<string, string[]>> {
const owners: Record<string, string[]> = {};
// Set to only have the unique values
for (const pod of new Set(pods)) {
const owner = await this.podStore.findAccount(pod);
if (!owner) {
this.logger.error(`Unable to find owner for ${pod}`);
for (const baseUrl of new Set(pods)) {
const pod = await this.podStore.findByBaseUrl(baseUrl);
if (!pod) {
this.logger.error(`Unable to find pod ${baseUrl}`);
continue;
}
owners[pod] = (await this.webIdStore.findLinks(owner)).map((link): string => link.webId);
const podOwners = await this.podStore.getOwners(pod.id);
if (!podOwners) {
this.logger.error(`Unable to find owners for ${baseUrl}`);
continue;
}
owners[baseUrl] = podOwners.map((owner): string => owner.webId);
}
return owners;
}

View File

@@ -0,0 +1,66 @@
import { boolean, object, string } from 'yup';
import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier';
import type { EmptyObject } from '../../../util/map/MapUtil';
import { parsePath, verifyAccountId } from '../account/util/AccountUtil';
import type { JsonRepresentation } from '../InteractionUtil';
import type { JsonInteractionHandlerInput } from '../JsonInteractionHandler';
import { JsonInteractionHandler } from '../JsonInteractionHandler';
import type { JsonView } from '../JsonView';
import { parseSchema, validateWithError } from '../YupUtil';
import type { PodIdRoute } from './PodIdRoute';
import type { PodStore } from './util/PodStore';
const inSchema = object({
webId: string().trim().required(),
visible: boolean().optional().default(false),
// If true: remove the WebID as owner
remove: boolean().optional().default(false),
});
/**
* Responsible for adding/updating/deleting owners in pods.
*/
export class UpdateOwnerHandler extends JsonInteractionHandler implements JsonView {
private readonly podStore: PodStore;
private readonly podRoute: PodIdRoute;
public constructor(podStore: PodStore, podRoute: PodIdRoute) {
super();
this.podStore = podStore;
this.podRoute = podRoute;
}
public async getView({ accountId, target }: JsonInteractionHandlerInput): Promise<JsonRepresentation> {
const pod = await this.findVerifiedPod(target, accountId);
const owners = await this.podStore.getOwners(pod.id);
return { json: { ...parseSchema(inSchema), baseUrl: pod?.baseUrl, owners }};
}
public async handle(input: JsonInteractionHandlerInput): Promise<JsonRepresentation<EmptyObject>> {
const { accountId, target, json } = input;
const { webId, visible, remove } = await validateWithError(inSchema, json);
const pod = await this.findVerifiedPod(target, accountId);
if (remove) {
await this.podStore.removeOwner(pod.id, webId);
} else {
await this.podStore.updateOwner(pod.id, webId, visible);
}
return { json: {}};
}
/**
* Extract the pod ID from the path and find the associated pod.
* Asserts that the given account ID is the creator of this pod.
*/
protected async findVerifiedPod(target: ResourceIdentifier, accountId?: string):
Promise<{ id: string; baseUrl: string; accountId: string }> {
const { podId } = parsePath(this.podRoute, target.path);
const pod = await this.podStore.get(podId);
verifyAccountId(accountId, pod?.accountId);
return { id: podId, ...pod };
}
}

View File

@@ -15,21 +15,38 @@ const STORAGE_DESCRIPTION = {
accountId: `id:${ACCOUNT_TYPE}`,
} as const;
const OWNER_TYPE = 'owner';
const OWNER_DESCRIPTION = {
webId: 'string',
visible: 'boolean',
podId: `id:${STORAGE_TYPE}`,
} as const;
/**
* A {@link PodStore} implementation using a {@link PodManager} to create pods
* and a {@link AccountLoginStorage} to store the data.
* Needs to be initialized before it can be used.
*
* Adds the initial WebID as the owner of the pod.
* By default, this owner is not exposed through a link header.
* This can be changed by setting the constructor `visible` parameter to `true`.
*/
export class BasePodStore extends Initializer implements PodStore {
private readonly logger = getLoggerFor(this);
private readonly storage: AccountLoginStorage<{ [STORAGE_TYPE]: typeof STORAGE_DESCRIPTION }>;
private readonly storage: AccountLoginStorage<{
[STORAGE_TYPE]: typeof STORAGE_DESCRIPTION;
[OWNER_TYPE]: typeof OWNER_DESCRIPTION; }>;
private readonly manager: PodManager;
private readonly visible: boolean;
private initialized = false;
public constructor(storage: AccountLoginStorage<any>, manager: PodManager) {
public constructor(storage: AccountLoginStorage<any>, manager: PodManager, visible = false) {
super();
this.storage = storage;
this.visible = visible;
this.manager = manager;
}
@@ -42,6 +59,8 @@ export class BasePodStore extends Initializer implements PodStore {
await this.storage.defineType(STORAGE_TYPE, STORAGE_DESCRIPTION, false);
await this.storage.createIndex(STORAGE_TYPE, 'accountId');
await this.storage.createIndex(STORAGE_TYPE, 'baseUrl');
await this.storage.defineType(OWNER_TYPE, OWNER_DESCRIPTION, false);
await this.storage.createIndex(OWNER_TYPE, 'podId');
this.initialized = true;
} catch (cause: unknown) {
throw new InternalServerError(`Error defining pods in storage: ${createErrorMessage(cause)}`,
@@ -53,6 +72,7 @@ export class BasePodStore extends Initializer implements PodStore {
// Adding pod to storage first as we cannot undo creating the pod below.
// This call might also fail because there is no login method yet on the account.
const pod = await this.storage.create(STORAGE_TYPE, { baseUrl: settings.base.path, accountId });
await this.storage.create(OWNER_TYPE, { podId: pod.id, webId: settings.webId, visible: this.visible });
try {
await this.manager.createPod(settings, overwrite);
@@ -66,16 +86,56 @@ export class BasePodStore extends Initializer implements PodStore {
return pod.id;
}
public async findAccount(baseUrl: string): Promise<string | undefined> {
public async get(id: string): Promise<{ baseUrl: string; accountId: string } | undefined> {
const pod = await this.storage.get(STORAGE_TYPE, id);
if (!pod) {
return;
}
return { baseUrl: pod.baseUrl, accountId: pod.accountId };
}
public async findByBaseUrl(baseUrl: string): Promise<{ id: string; accountId: string } | undefined> {
const result = await this.storage.find(STORAGE_TYPE, { baseUrl });
if (result.length === 0) {
return;
}
return result[0].accountId;
return { id: result[0].id, accountId: result[0].accountId };
}
public async findPods(accountId: string): Promise<{ id: string; baseUrl: string }[]> {
return (await this.storage.find(STORAGE_TYPE, { accountId }))
.map(({ id, baseUrl }): { id: string; baseUrl: string } => ({ id, baseUrl }));
}
public async getOwners(id: string): Promise<{ webId: string; visible: boolean }[] | undefined> {
const results = await this.storage.find(OWNER_TYPE, { podId: id });
if (results.length === 0) {
return;
}
return results.map((result): { webId: string; visible: boolean } =>
({ webId: result.webId, visible: result.visible }));
}
public async updateOwner(id: string, webId: string, visible: boolean): Promise<void> {
// Need to first check if there already is an owner with the given WebID
// so we know if we need to create or update.
const matches = await this.storage.find(OWNER_TYPE, { webId, podId: id });
if (matches.length === 0) {
await this.storage.create(OWNER_TYPE, { webId, visible, podId: id });
} else {
await this.storage.setField(OWNER_TYPE, matches[0].id, 'visible', visible);
}
}
public async removeOwner(id: string, webId: string): Promise<void> {
const owners = await this.storage.find(OWNER_TYPE, { podId: id });
const match = owners.find((owner): boolean => owner.webId === webId);
if (!match) {
return;
}
if (owners.length === 1) {
throw new BadRequestHttpError('Unable to remove the last owner of a pod.');
}
await this.storage.delete(OWNER_TYPE, match.id);
}
}

View File

@@ -0,0 +1,75 @@
import { Util } from 'n3';
import type { MetadataWriterInput } from '../../../../http/output/metadata/MetadataWriter';
import { MetadataWriter } from '../../../../http/output/metadata/MetadataWriter';
import type { ResourceIdentifier } from '../../../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../../../logging/LogUtil';
import type { StorageLocationStrategy } from '../../../../server/description/StorageLocationStrategy';
import { createErrorMessage } from '../../../../util/errors/ErrorUtil';
import { addHeader } from '../../../../util/HeaderUtil';
import type { PodStore } from './PodStore';
import isBlankNode = Util.isBlankNode;
/**
* Adds link headers indicating who the owners are when accessing the base URL of a pod.
* Only owners that have decided to be visible will be shown.
*
* Solid, §4.1: "When a server wants to advertise the owner of a storage,
* the server MUST include the Link header with rel="http://www.w3.org/ns/solid/terms#owner"
* targeting the URI of the owner in the response of HTTP HEAD or GET requests targeting the root container."
* https://solidproject.org/TR/2022/protocol-20221231#server-storage-link-owner
*/
export class OwnerMetadataWriter extends MetadataWriter {
protected logger = getLoggerFor(this);
protected podStore: PodStore;
protected storageStrategy: StorageLocationStrategy;
public constructor(podStore: PodStore, storageStrategy: StorageLocationStrategy) {
super();
this.podStore = podStore;
this.storageStrategy = storageStrategy;
}
public async handle({ metadata, response }: MetadataWriterInput): Promise<void> {
// Doing all checks here instead of in `canHandle` as this is currently used in a ParallelHandler,
// which doesn't correctly check the canHandle/handle combination.
if (isBlankNode(metadata.identifier)) {
// Blank nodes indicate errors
this.logger.debug('Skipping owner link headers as metadata identifier is a blank node.');
return;
}
const identifier = { path: metadata.identifier.value };
let storageIdentifier: ResourceIdentifier;
try {
storageIdentifier = await this.storageStrategy.getStorageIdentifier(identifier);
} catch (error: unknown) {
this.logger
.debug(`Skipping owner link headers as no storage identifier could be found: ${createErrorMessage(error)}`);
return;
}
// Only need to expose headers when requesting the base URl of the pod
if (identifier.path !== storageIdentifier.path) {
return;
}
const pod = await this.podStore.findByBaseUrl(identifier.path);
if (!pod) {
this.logger.debug(`No pod object found for base URL ${identifier.path}`);
return;
}
const owners = await this.podStore.getOwners(pod.id);
if (!owners) {
this.logger.error(`Unable to find owners for pod ${identifier.path}`);
return;
}
for (const { webId, visible } of owners) {
if (visible) {
addHeader(response, 'Link', `<${webId}>; rel="http://www.w3.org/ns/solid/terms#owner"`);
}
}
}
}

View File

@@ -2,12 +2,14 @@ import type { PodSettings } from '../../../../pods/settings/PodSettings';
/**
* Can be used to create new pods and find relevant information.
* Also keeps track of the owners of a pod.
* The `visible` parameter indicates if an owner should be publicly exposed or not.
*/
export interface PodStore {
/**
* Creates a new pod and updates the account accordingly.
*
* @param accountId - Identifier of the account that is creating the account..
* @param accountId - Identifier of the account that is creating the account.
* @param settings - Settings to create a pod with.
* @param overwrite - If the pod is allowed to overwrite existing data.
*
@@ -15,12 +17,19 @@ export interface PodStore {
*/
create: (accountId: string, settings: PodSettings, overwrite: boolean) => Promise<string>;
/**
* Returns the baseURl and account that created the pod for the given pod ID.
*
* @param id - ID of the pod.
*/
get: (id: string) => Promise<{ baseUrl: string; accountId: string } | undefined>;
/**
* Find the ID of the account that created the given pod.
*
* @param baseUrl - The pod base URL.
*/
findAccount: (baseUrl: string) => Promise<string | undefined>;
findByBaseUrl: (baseUrl: string) => Promise<{ id: string; accountId: string } | undefined>;
/**
* Find all the pod resources created by the given account ID.
@@ -28,4 +37,32 @@ export interface PodStore {
* @param accountId - Account ID to find pod resources for.
*/
findPods: (accountId: string) => Promise<{ id: string; baseUrl: string }[]>;
/**
* Find all owners for the given pod ID.
*
* @param id - ID of the pod.
*/
getOwners: (id: string) => Promise<{ webId: string; visible: boolean }[] | undefined>;
/**
* Add or update an owner of a pod.
* In case there already is an owner with this WebID, it will be updated,
* otherwise a new owner will be added.
*
* @param id - ID of the pod.
* @param webId - WebID of the owner.
* @param visible - Whether the owner wants to be exposed or not.
*/
updateOwner: (id: string, webId: string, visible: boolean) => Promise<void>;
/**
* Remove an owner from a pod.
* This should not remove the last owner as a pod always needs to have at least one owner.
* https://solidproject.org/TR/2022/protocol-20221231#server-storage-track-owner
*
* @param id - ID of the pod.
* @param webId - WebID of the owner.
*/
removeOwner: (id: string, webId: string) => Promise<void>;
}

View File

@@ -97,9 +97,9 @@ export class LinkWebIdHandler extends JsonInteractionHandler<OutType> implements
// Only need to check ownership if the account did not create the pod
let isCreator = false;
try {
const pod = await this.storageStrategy.getStorageIdentifier({ path: webId });
const creator = await this.podStore.findAccount(pod.path);
isCreator = accountId === creator;
const baseUrl = await this.storageStrategy.getStorageIdentifier({ path: webId });
const pod = await this.podStore.findByBaseUrl(baseUrl.path);
isCreator = accountId === pod?.accountId;
} catch {
// Probably a WebID not hosted on the server
}

View File

@@ -202,11 +202,13 @@ export * from './identity/interaction/password/UpdatePasswordHandler';
// Identity/Interaction/Pod/Util
export * from './identity/interaction/pod/util/BasePodStore';
export * from './identity/interaction/pod/util/OwnerMetadataWriter';
export * from './identity/interaction/pod/util/PodStore';
// Identity/Interaction/Pod
export * from './identity/interaction/pod/CreatePodHandler';
export * from './identity/interaction/pod/PodIdRoute';
export * from './identity/interaction/pod/UpdateOwnerHandler';
// Identity/Interaction/Routing
export * from './identity/interaction/routing/AbsolutePathInteractionRoute';