Skip to content

Commit

Permalink
feat: Always grant control permissions to pod owners
Browse files Browse the repository at this point in the history
  • Loading branch information
joachimvh committed Aug 31, 2021
1 parent de51b6b commit abf8f59
Show file tree
Hide file tree
Showing 11 changed files with 216 additions and 14 deletions.
11 changes: 11 additions & 0 deletions config/ldp/authorization/authorizers/ownership.json
@@ -0,0 +1,11 @@
{
"@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:OwnershipAuthorizer",
"@type": "OwnershipAuthorizer",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
}
]
}
11 changes: 11 additions & 0 deletions config/ldp/authorization/authorizers/path-based.json
@@ -0,0 +1,11 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@graph": [
{
"comment": "This authorizer will be used to prevent external access to containers used for internal storage.",
"@id": "urn:solid-server:default:PathBasedAuthorizer",
"@type": "PathBasedAuthorizer",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
}
]
}
12 changes: 5 additions & 7 deletions config/ldp/authorization/webacl.json
@@ -1,20 +1,18 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"import": [
"files-scs:config/ldp/authorization/authorizers/acl.json"
"files-scs:config/ldp/authorization/authorizers/acl.json",
"files-scs:config/ldp/authorization/authorizers/ownership.json",
"files-scs:config/ldp/authorization/authorizers/path-based.json"
],
"@graph": [
{
"comment": "Uses Web Access Control for authorization.",
"@id": "urn:solid-server:default:Authorizer",
"@type": "WaterfallHandler",
"handlers": [
{
"comment": "This authorizer will be used to prevent external access to containers used for internal storage.",
"@id": "urn:solid-server:default:PathBasedAuthorizer",
"@type": "PathBasedAuthorizer",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
},
{ "@id": "urn:solid-server:default:OwnershipAuthorizer" },
{ "@id": "urn:solid-server:default:PathBasedAuthorizer" },
{
"comment": "This authorizer makes sure that for auxiliary resources, the main authorizer gets called with the associated identifier.",
"@type": "AuxiliaryAuthorizer",
Expand Down
41 changes: 41 additions & 0 deletions src/authorization/OwnershipAuthorizer.ts
@@ -0,0 +1,41 @@
import type { AccountSettings, AccountStore } from '../identity/interaction/email-password/storage/AccountStore';
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
import type { Authorization } from './Authorization';
import type { AuthorizerArgs } from './Authorizer';
import { Authorizer } from './Authorizer';

/**
* Authorizer used to make sure that owners always will be able to change the permissions on data stored in their pod.
*/
export class OwnershipAuthorizer extends Authorizer {
private readonly accountStore: AccountStore;

public constructor(accountStore: AccountStore) {
super();
this.accountStore = accountStore;
}

public async canHandle({ credentials, identifier, permissions }: AuthorizerArgs): Promise<void> {
if (Object.entries(permissions).some(([ name, value ]): boolean => value && name !== 'control')) {
throw new NotImplementedHttpError('Only control permissions are supported.');
}
if (!credentials.webId) {
throw new NotImplementedHttpError('Only authenticated requests are supported.');
}
let settings: AccountSettings;
try {
settings = await this.accountStore.getSettings(credentials.webId);
} catch {
throw new NotImplementedHttpError('Only requests by registered WebIDs are supported.');
}
if (!settings.podBaseUrl || !identifier.path.startsWith(settings.podBaseUrl)) {
throw new NotImplementedHttpError('Only requests targeting the pod registered to this WebID are supported.');
}
}

public async handle(): Promise<Authorization> {
// If all checks in the canHandle function pass permission is always granted
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { addMetadata(): void {} };
}
}
Expand Up @@ -138,6 +138,7 @@ export class RegistrationHandler extends InteractionHandler {
// Register the account
const settings: AccountSettings = {
useIdp: result.register,
podBaseUrl: podBaseUrl?.path,
};
await this.accountStore.create(result.email, result.webId!, result.password, settings);

Expand Down
Expand Up @@ -6,6 +6,10 @@ export interface AccountSettings {
* If this account can be used to identify as the corresponding WebID in the IDP.
*/
useIdp: boolean;
/**
* The base URL of the pod associated with this account, if there is one.
*/
podBaseUrl?: string;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -13,6 +13,7 @@ export * from './authorization/Authorization';
export * from './authorization/Authorizer';
export * from './authorization/AuxiliaryAuthorizer';
export * from './authorization/DenyAllAuthorizer';
export * from './authorization/OwnershipAuthorizer';
export * from './authorization/PathBasedAuthorizer';
export * from './authorization/WebAclAuthorization';
export * from './authorization/WebAclAuthorizer';
Expand Down
37 changes: 37 additions & 0 deletions test/integration/Identity.test.ts
Expand Up @@ -334,6 +334,43 @@ describe('A Solid server with IDP', (): void => {
res = await state.session.fetch(newWebId, patchOptions);
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);

// Owner locked is locked out
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 => {
Expand Down
1 change: 1 addition & 0 deletions test/integration/config/ldp-with-auth.json
Expand Up @@ -7,6 +7,7 @@
"files-scs:config/http/middleware/no-websockets.json",
"files-scs:config/http/server-factory/no-websockets.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/authorization/webacl.json",
"files-scs:config/ldp/handler/default.json",
Expand Down
96 changes: 96 additions & 0 deletions test/unit/authorization/OwnershipAuthorizer.test.ts
@@ -0,0 +1,96 @@
import type { Credentials } from '../../../src/authentication/Credentials';
import { OwnershipAuthorizer } from '../../../src/authorization/OwnershipAuthorizer';
import type {
AccountSettings,
AccountStore,
} from '../../../src/identity/interaction/email-password/storage/AccountStore';
import type { PermissionSet } from '../../../src/ldp/permissions/PermissionSet';
import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';

describe('An OwnershipAuthorizer', (): void => {
const owner = 'http://test.com/alice/profile/card#me';
const podBaseUrl = 'http://test.com/alice/';
let credentials: Credentials;
let identifier: ResourceIdentifier;
let permissions: PermissionSet;
let settings: AccountSettings;
let accountStore: jest.Mocked<AccountStore>;
let authorizer: OwnershipAuthorizer;

beforeEach(async(): Promise<void> => {
credentials = {};

identifier = { path: 'http://test.com/random/location/' };

permissions = {
read: false,
write: false,
append: false,
control: false,
};

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;

authorizer = new OwnershipAuthorizer(accountStore);
});

it('only handles control permission requests.', async(): Promise<void> => {
permissions.read = true;
await expect(authorizer.canHandle({ credentials, identifier, permissions }))
.rejects.toThrow('Only control permissions are supported.');
});

it('requires WebID credentials.', async(): Promise<void> => {
permissions.control = true;
await expect(authorizer.canHandle({ credentials, identifier, permissions }))
.rejects.toThrow('Only authenticated requests are supported.');
});

it('requires the WebID to have an account.', async(): Promise<void> => {
permissions.control = true;
credentials.webId = 'http://test.com/someone/else';
await expect(authorizer.canHandle({ credentials, identifier, permissions }))
.rejects.toThrow('Only requests by registered WebIDs are supported.');
});

it('requires the registered account to have a pod.', async(): Promise<void> => {
delete settings.podBaseUrl;
permissions.control = true;
credentials.webId = owner;
await expect(authorizer.canHandle({ credentials, identifier, permissions }))
.rejects.toThrow('Only requests targeting the pod registered to this WebID are supported.');
});

it('requires the target identifier to be part of the pod.', async(): Promise<void> => {
permissions.control = true;
credentials.webId = owner;
await expect(authorizer.canHandle({ credentials, identifier, permissions }))
.rejects.toThrow('Only requests targeting the pod registered to this WebID are supported.');
});

it('can handle all request passing the above checks.', async(): Promise<void> => {
permissions.control = true;
credentials.webId = owner;
identifier.path = podBaseUrl;
await expect(authorizer.canHandle({ credentials, identifier, permissions }))
.resolves.toBeUndefined();
});

it('allows all requests that passed the canHandle checks.', async(): Promise<void> => {
const prom = authorizer.handle();
await expect(prom).resolves.toEqual(expect.objectContaining({ addMetadata: expect.any(Function) }));
expect((await prom).addMetadata(null as any)).toBeUndefined();
});
});
Expand Up @@ -160,7 +160,7 @@ describe('A RegistrationHandler', (): void => {
email,
webId,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
podBaseUrl,
createWebId: false,
register: false,
createPod: true,
Expand All @@ -175,7 +175,7 @@ describe('A RegistrationHandler', (): void => {
expect(podManager.createPod).toHaveBeenCalledTimes(1);
expect(podManager.createPod).toHaveBeenLastCalledWith({ path: `${baseUrl}${podName}/` }, podSettings);
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.deleteAccount).toHaveBeenCalledTimes(0);
Expand All @@ -190,7 +190,7 @@ describe('A RegistrationHandler', (): void => {
email,
webId,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
podBaseUrl,
createWebId: false,
register: true,
createPod: true,
Expand All @@ -201,7 +201,7 @@ describe('A RegistrationHandler', (): void => {
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
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).toHaveBeenLastCalledWith(podName);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
Expand All @@ -222,7 +222,7 @@ describe('A RegistrationHandler', (): void => {
expect(ownershipValidator.handleSafe).toHaveBeenCalledTimes(1);
expect(ownershipValidator.handleSafe).toHaveBeenLastCalledWith({ webId });
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).toHaveBeenLastCalledWith(podName);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
Expand All @@ -245,7 +245,7 @@ describe('A RegistrationHandler', (): void => {
email,
webId: generatedWebID,
oidcIssuer: baseUrl,
podBaseUrl: `${baseUrl}${podName}/`,
podBaseUrl,
createWebId: true,
register: true,
createPod: true,
Expand All @@ -256,7 +256,8 @@ describe('A RegistrationHandler', (): void => {
expect(identifierGenerator.generate).toHaveBeenCalledTimes(1);
expect(identifierGenerator.generate).toHaveBeenLastCalledWith(podName);
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).toHaveBeenLastCalledWith(email);
expect(podManager.createPod).toHaveBeenCalledTimes(1);
Expand Down

0 comments on commit abf8f59

Please sign in to comment.