mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for pod owners
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
66
src/identity/interaction/pod/UpdateOwnerHandler.ts
Normal file
66
src/identity/interaction/pod/UpdateOwnerHandler.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
75
src/identity/interaction/pod/util/OwnerMetadataWriter.ts
Normal file
75
src/identity/interaction/pod/util/OwnerMetadataWriter.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user