mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Always grant control permissions to pod owners
This commit is contained in:
parent
6c4ccb334d
commit
8f5d61911d
12
config/ldp/authorization/readers/ownership.json
Normal file
12
config/ldp/authorization/readers/ownership.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "Allows pod owners to always edit permissions on the data.",
|
||||||
|
"@id": "urn:solid-server:default:OwnerPermissionReader",
|
||||||
|
"@type": "OwnerPermissionReader",
|
||||||
|
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
||||||
|
"aclStrategy": { "@id": "urn:solid-server:default:AclStrategy" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
||||||
"import": [
|
"import": [
|
||||||
"files-scs:config/ldp/authorization/readers/acl.json"
|
"files-scs:config/ldp/authorization/readers/acl.json",
|
||||||
|
"files-scs:config/ldp/authorization/readers/ownership.json"
|
||||||
],
|
],
|
||||||
"@graph": [
|
"@graph": [
|
||||||
{
|
{
|
||||||
@ -15,6 +16,7 @@
|
|||||||
"@type": "PathBasedReader",
|
"@type": "PathBasedReader",
|
||||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
|
||||||
},
|
},
|
||||||
|
{ "@id": "urn:solid-server:default:OwnerPermissionReader" },
|
||||||
{
|
{
|
||||||
"comment": "This PermissionReader makes sure that for auxiliary resources, the main reader gets called with the associated identifier.",
|
"comment": "This PermissionReader makes sure that for auxiliary resources, the main reader gets called with the associated identifier.",
|
||||||
"@type": "AuxiliaryReader",
|
"@type": "AuxiliaryReader",
|
||||||
|
70
src/authorization/OwnerPermissionReader.ts
Normal file
70
src/authorization/OwnerPermissionReader.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { CredentialGroup } from '../authentication/Credentials';
|
||||||
|
import type { AccountSettings, AccountStore } from '../identity/interaction/email-password/storage/AccountStore';
|
||||||
|
import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy';
|
||||||
|
import type { AclPermission } from '../ldp/permissions/AclPermission';
|
||||||
|
import type { PermissionSet } from '../ldp/permissions/Permissions';
|
||||||
|
import { getLoggerFor } from '../logging/LogUtil';
|
||||||
|
import { createErrorMessage } from '../util/errors/ErrorUtil';
|
||||||
|
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
|
||||||
|
import type { PermissionReaderInput } from './PermissionReader';
|
||||||
|
import { PermissionReader } from './PermissionReader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows control access if the request is being made by the owner of the pod containing the resource.
|
||||||
|
*/
|
||||||
|
export class OwnerPermissionReader extends PermissionReader {
|
||||||
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
|
private readonly accountStore: AccountStore;
|
||||||
|
private readonly aclStrategy: AuxiliaryIdentifierStrategy;
|
||||||
|
|
||||||
|
public constructor(accountStore: AccountStore, aclStrategy: AuxiliaryIdentifierStrategy) {
|
||||||
|
super();
|
||||||
|
this.accountStore = accountStore;
|
||||||
|
this.aclStrategy = aclStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handle(input: PermissionReaderInput): Promise<PermissionSet> {
|
||||||
|
try {
|
||||||
|
await this.ensurePodOwner(input);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.debug(`No pod owner Control permissions: ${createErrorMessage(error)}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
this.logger.debug(`Granting Control permissions to owner on ${input.identifier.path}`);
|
||||||
|
|
||||||
|
return { [CredentialGroup.agent]: {
|
||||||
|
read: true,
|
||||||
|
write: true,
|
||||||
|
append: true,
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
control: true,
|
||||||
|
} as AclPermission };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that all conditions are fulfilled to give the owner access.
|
||||||
|
*/
|
||||||
|
private async ensurePodOwner({ credentials, identifier }: PermissionReaderInput): Promise<void> {
|
||||||
|
// We only check ownership when an ACL resource is targeted to reduce the number of storage calls
|
||||||
|
if (!this.aclStrategy.isAuxiliaryIdentifier(identifier)) {
|
||||||
|
throw new NotImplementedHttpError('Exception is only granted when accessing ACL resources');
|
||||||
|
}
|
||||||
|
if (!credentials.agent?.webId) {
|
||||||
|
throw new NotImplementedHttpError('Only authenticated agents could be owners');
|
||||||
|
}
|
||||||
|
let settings: AccountSettings;
|
||||||
|
try {
|
||||||
|
settings = await this.accountStore.getSettings(credentials.agent.webId);
|
||||||
|
} catch {
|
||||||
|
throw new NotImplementedHttpError('No account registered for this WebID');
|
||||||
|
}
|
||||||
|
if (!settings.podBaseUrl) {
|
||||||
|
throw new NotImplementedHttpError('This agent has no pod on the server');
|
||||||
|
}
|
||||||
|
if (!identifier.path.startsWith(settings.podBaseUrl)) {
|
||||||
|
throw new NotImplementedHttpError('Not targeting the pod owned by this agent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,10 @@ export interface AccountSettings {
|
|||||||
* If this account can be used to identify as the corresponding WebID in the IDP.
|
* If this account can be used to identify as the corresponding WebID in the IDP.
|
||||||
*/
|
*/
|
||||||
useIdp: boolean;
|
useIdp: boolean;
|
||||||
|
/**
|
||||||
|
* The base URL of the pod associated with this account, if there is one.
|
||||||
|
*/
|
||||||
|
podBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -191,6 +191,7 @@ export class RegistrationManager {
|
|||||||
// Register the account
|
// Register the account
|
||||||
const settings: AccountSettings = {
|
const settings: AccountSettings = {
|
||||||
useIdp: input.register,
|
useIdp: input.register,
|
||||||
|
podBaseUrl: podBaseUrl?.path,
|
||||||
};
|
};
|
||||||
await this.accountStore.create(input.email, input.webId!, input.password, settings);
|
await this.accountStore.create(input.email, input.webId!, input.password, settings);
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export * from './authorization/access-checkers/AgentClassAccessChecker';
|
|||||||
export * from './authorization/access-checkers/AgentGroupAccessChecker';
|
export * from './authorization/access-checkers/AgentGroupAccessChecker';
|
||||||
|
|
||||||
// Authorization
|
// Authorization
|
||||||
|
export * from './authorization/OwnerPermissionReader';
|
||||||
export * from './authorization/AllStaticReader';
|
export * from './authorization/AllStaticReader';
|
||||||
export * from './authorization/Authorizer';
|
export * from './authorization/Authorizer';
|
||||||
export * from './authorization/AuxiliaryReader';
|
export * from './authorization/AuxiliaryReader';
|
||||||
|
@ -328,6 +328,43 @@ describe('A Solid server with IDP', (): void => {
|
|||||||
res = await state.session.fetch(newWebId, patchOptions);
|
res = await state.session.fetch(newWebId, patchOptions);
|
||||||
expect(res.status).toBe(205);
|
expect(res.status).toBe(205);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('always has control over data in the pod.', async(): Promise<void> => {
|
||||||
|
const podBaseUrl = `${baseUrl}${podName}/`;
|
||||||
|
const brokenAcl = '<#authorization> a <http://www.w3.org/ns/auth/acl#Authorization> .';
|
||||||
|
|
||||||
|
// Make the acl file unusable
|
||||||
|
let res = await state.session.fetch(`${podBaseUrl}.acl`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'text/turtle' },
|
||||||
|
body: brokenAcl,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(205);
|
||||||
|
|
||||||
|
// The owner is locked out of their own pod due to a faulty acl file
|
||||||
|
res = await state.session.fetch(podBaseUrl);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
|
||||||
|
const fixedAcl = `@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
||||||
|
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
||||||
|
|
||||||
|
<#authorization>
|
||||||
|
a acl:Authorization;
|
||||||
|
acl:agentClass foaf:Agent;
|
||||||
|
acl:mode acl:Read;
|
||||||
|
acl:accessTo <./>.`;
|
||||||
|
// Owner can still update the acl
|
||||||
|
res = await state.session.fetch(`${podBaseUrl}.acl`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'text/turtle' },
|
||||||
|
body: fixedAcl,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(205);
|
||||||
|
|
||||||
|
// Access is possible again
|
||||||
|
res = await state.session.fetch(podBaseUrl);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setup', (): void => {
|
describe('setup', (): void => {
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
"files-scs:config/http/middleware/no-websockets.json",
|
"files-scs:config/http/middleware/no-websockets.json",
|
||||||
"files-scs:config/http/server-factory/no-websockets.json",
|
"files-scs:config/http/server-factory/no-websockets.json",
|
||||||
"files-scs:config/http/static/default.json",
|
"files-scs:config/http/static/default.json",
|
||||||
|
"files-scs:config/identity/handler/default.json",
|
||||||
"files-scs:config/ldp/authentication/debug-auth-header.json",
|
"files-scs:config/ldp/authentication/debug-auth-header.json",
|
||||||
"files-scs:config/ldp/authorization/webacl.json",
|
"files-scs:config/ldp/authorization/webacl.json",
|
||||||
"files-scs:config/ldp/handler/default.json",
|
"files-scs:config/ldp/handler/default.json",
|
||||||
|
84
test/unit/authorization/OwnerPermissionReader.test.ts
Normal file
84
test/unit/authorization/OwnerPermissionReader.test.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type { CredentialSet } from '../../../src/authentication/Credentials';
|
||||||
|
import { CredentialGroup } from '../../../src/authentication/Credentials';
|
||||||
|
import { OwnerPermissionReader } from '../../../src/authorization/OwnerPermissionReader';
|
||||||
|
import type {
|
||||||
|
AccountSettings,
|
||||||
|
AccountStore,
|
||||||
|
} from '../../../src/identity/interaction/email-password/storage/AccountStore';
|
||||||
|
import type { AuxiliaryIdentifierStrategy } from '../../../src/ldp/auxiliary/AuxiliaryIdentifierStrategy';
|
||||||
|
import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
|
||||||
|
|
||||||
|
describe('An OwnerPermissionReader', (): void => {
|
||||||
|
const owner = 'http://test.com/alice/profile/card#me';
|
||||||
|
const podBaseUrl = 'http://test.com/alice/';
|
||||||
|
let credentials: CredentialSet;
|
||||||
|
let identifier: ResourceIdentifier;
|
||||||
|
let settings: AccountSettings;
|
||||||
|
let accountStore: jest.Mocked<AccountStore>;
|
||||||
|
let aclStrategy: jest.Mocked<AuxiliaryIdentifierStrategy>;
|
||||||
|
let reader: OwnerPermissionReader;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
credentials = { [CredentialGroup.agent]: { webId: owner }};
|
||||||
|
|
||||||
|
identifier = { path: `${podBaseUrl}.acl` };
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
useIdp: true,
|
||||||
|
podBaseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
accountStore = {
|
||||||
|
getSettings: jest.fn(async(webId: string): Promise<AccountSettings> => {
|
||||||
|
if (webId === owner) {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
throw new Error('No account');
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
aclStrategy = {
|
||||||
|
isAuxiliaryIdentifier: jest.fn((id): boolean => id.path.endsWith('.acl')),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
reader = new OwnerPermissionReader(accountStore, aclStrategy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty permissions for non-ACL resources.', async(): Promise<void> => {
|
||||||
|
identifier.path = podBaseUrl;
|
||||||
|
await expect(reader.handle({ credentials, identifier })).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty permissions if there is no agent WebID.', async(): Promise<void> => {
|
||||||
|
credentials = {};
|
||||||
|
await expect(reader.handle({ credentials, identifier })).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty permissions if the agent has no account.', async(): Promise<void> => {
|
||||||
|
credentials.agent!.webId = 'http://test.com/someone/else';
|
||||||
|
await expect(reader.handle({ credentials, identifier })).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty permissions if the account has no pod.', async(): Promise<void> => {
|
||||||
|
delete settings.podBaseUrl;
|
||||||
|
await expect(reader.handle({ credentials, identifier })).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty permissions if the target identifier is not in the pod.', async(): Promise<void> => {
|
||||||
|
identifier.path = 'http://somewhere.else/.acl';
|
||||||
|
await expect(reader.handle({ credentials, identifier })).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns full permissions if the owner is accessing an ACL resource in their pod.', async(): Promise<void> => {
|
||||||
|
await expect(reader.handle({ credentials, identifier })).resolves.toEqual({
|
||||||
|
[CredentialGroup.agent]: {
|
||||||
|
read: true,
|
||||||
|
write: true,
|
||||||
|
append: true,
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
control: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -200,7 +200,7 @@ describe('A RegistrationManager', (): void => {
|
|||||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||||
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false);
|
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings, false);
|
||||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: false });
|
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: false, podBaseUrl });
|
||||||
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
|
expect(accountStore.deleteAccount).toHaveBeenCalledTimes(0);
|
||||||
@ -222,7 +222,7 @@ describe('A RegistrationManager', (): void => {
|
|||||||
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
||||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true });
|
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl });
|
||||||
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
||||||
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
||||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||||
@ -242,7 +242,7 @@ describe('A RegistrationManager', (): void => {
|
|||||||
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
|
||||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true });
|
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: true, podBaseUrl });
|
||||||
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
||||||
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
||||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||||
@ -272,7 +272,10 @@ describe('A RegistrationManager', (): void => {
|
|||||||
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
|
||||||
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
|
||||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, generatedWebID, password, { useIdp: true });
|
expect(accountStore.create).toHaveBeenLastCalledWith(email,
|
||||||
|
generatedWebID,
|
||||||
|
password,
|
||||||
|
{ useIdp: true, podBaseUrl });
|
||||||
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
||||||
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
|
expect(accountStore.verify).toHaveBeenLastCalledWith(email);
|
||||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||||
@ -300,7 +303,10 @@ describe('A RegistrationManager', (): void => {
|
|||||||
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
expect(podManager.createPod).toHaveBeenCalledTimes(1);
|
||||||
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: baseUrl }, podSettings, true);
|
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: baseUrl }, podSettings, true);
|
||||||
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
expect(accountStore.create).toHaveBeenCalledTimes(1);
|
||||||
expect(accountStore.create).toHaveBeenLastCalledWith(email, webId, password, { useIdp: false });
|
expect(accountStore.create).toHaveBeenLastCalledWith(email,
|
||||||
|
webId,
|
||||||
|
password,
|
||||||
|
{ useIdp: false, podBaseUrl: baseUrl });
|
||||||
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
expect(accountStore.verify).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
expect(identifierGenerator.generate).toHaveBeenCalledTimes(0);
|
expect(identifierGenerator.generate).toHaveBeenCalledTimes(0);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user