Repository: solid/community-server (ts)

Issue:
Cannot PUT a file without Append permissions
Write permissions should be enough (and they should imply Append)

Pull request description:
fix: Update acl authorizer to make write rights imply append rights
Closes #481 

Patch:
diff --git a/src/authorization/WebAclAuthorizer.ts b/src/authorization/WebAclAuthorizer.ts
index f464c428d..66565e2bd 100644
--- a/src/authorization/WebAclAuthorizer.ts
+++ b/src/authorization/WebAclAuthorizer.ts
@@ -64,7 +64,13 @@ export class WebAclAuthorizer extends Authorizer {
    */
   private checkPermission(agent: Credentials, store: Store, mode: string): void {
     const modeString = ACL[this.capitalize(mode) as 'Write' | 'Read' | 'Append' | 'Control'];
-    const auths = store.getQuads(null, ACL.mode, modeString, null).map((quad: Quad): Term => quad.subject);
+    const auths = this.getModePermissions(store, modeString);
+
+    // Having write permissions implies having append permissions
+    if (modeString === ACL.Append) {
+      auths.push(...this.getModePermissions(store, ACL.Write));
+    }
+
     if (!auths.some((term): boolean => this.hasAccess(agent, term, store))) {
       const isLoggedIn = typeof agent.webId === 'string';
       if (isLoggedIn) {
@@ -87,6 +93,15 @@ export class WebAclAuthorizer extends Authorizer {
     return `${mode[0].toUpperCase()}${mode.slice(1).toLowerCase()}`;
   }
 
+  /**
+   * Returns the identifiers of all authorizations that grant the given mode access for a resource.
+   * @param store - The store containing the quads of the acl resource.
+   * @param aclMode - A valid acl mode (ACL.Write/Read/...)
+   */
+  private getModePermissions(store: Store, aclMode: string): Term[] {
+    return store.getQuads(null, ACL.mode, aclMode, null).map((quad: Quad): Term => quad.subject);
+  }
+
   /**
    * Checks if the given agent has access to the modes specified by the given authorization.
    * @param agent - Credentials of agent that needs access.
diff --git a/src/index.ts b/src/index.ts
index 36d4f3c6e..c28cb1bd2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -153,6 +153,7 @@ export * from './storage/routing/RouterRule';
 
 // Storage
 export * from './storage/AtomicResourceStore';
+export * from './storage/BaseResourceStore';
 export * from './storage/Conditions';
 export * from './storage/DataAccessorBasedStore';
 export * from './storage/LockingResourceStore';
diff --git a/src/storage/BaseResourceStore.ts b/src/storage/BaseResourceStore.ts
new file mode 100644
index 000000000..35e9c1258
--- /dev/null
+++ b/src/storage/BaseResourceStore.ts
@@ -0,0 +1,36 @@
+import type { Patch } from '../ldp/http/Patch';
+import type { Representation } from '../ldp/representation/Representation';
+import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences';
+import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier';
+import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
+import type { Conditions } from './Conditions';
+import type { ResourceStore } from './ResourceStore';
+
+/**
+ * Base implementation of ResourceStore for implementers of custom stores.
+ */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+export class BaseResourceStore implements ResourceStore {
+  public async addResource(container: ResourceIdentifier, representation: Representation,
+    conditions?: Conditions): Promise<ResourceIdentifier> {
+    throw new NotImplementedHttpError();
+  }
+
+  public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise<void> {
+    throw new NotImplementedHttpError();
+  }
+
+  public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
+    conditions?: Conditions): Promise<Representation> {
+    throw new NotImplementedHttpError();
+  }
+
+  public async modifyResource(identifier: ResourceIdentifier, patch: Patch, conditions?: Conditions): Promise<void> {
+    throw new NotImplementedHttpError();
+  }
+
+  public async setRepresentation(identifier: ResourceIdentifier, representation: Representation,
+    conditions?: Conditions): Promise<void> {
+    throw new NotImplementedHttpError();
+  }
+}


Test patch:
diff --git a/test/unit/authorization/WebAclAuthorizer.test.ts b/test/unit/authorization/WebAclAuthorizer.test.ts
index c3719d68a..79ee7460e 100644
--- a/test/unit/authorization/WebAclAuthorizer.test.ts
+++ b/test/unit/authorization/WebAclAuthorizer.test.ts
@@ -155,4 +155,20 @@ describe('A WebAclAuthorizer', (): void => {
     await expect(promise).rejects.toThrow('No ACL document found for root container');
     await expect(promise).rejects.toThrow(InternalServerError);
   });
+
+  it('allows an agent to append if they have write access.', async(): Promise<void> => {
+    credentials.webId = 'http://test.com/user';
+    identifier.path = 'http://test.com/foo';
+    permissions = {
+      read: false,
+      write: false,
+      append: true,
+    };
+    store.getRepresentation = async(): Promise<Representation> => ({ data: streamifyArray([
+      quad(nn('auth'), nn(`${acl}agent`), nn(credentials.webId!)),
+      quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)),
+      quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)),
+    ]) } as Representation);
+    await expect(authorizer.handle({ identifier, permissions, credentials })).resolves.toBeUndefined();
+  });
 });
diff --git a/test/unit/storage/BaseResourceStore.test.ts b/test/unit/storage/BaseResourceStore.test.ts
new file mode 100644
index 000000000..791e42e72
--- /dev/null
+++ b/test/unit/storage/BaseResourceStore.test.ts
@@ -0,0 +1,27 @@
+import { BaseResourceStore } from '../../../src/storage/BaseResourceStore';
+import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError';
+
+const any: any = {};
+
+describe('A BaseResourceStore', (): void => {
+  const store = new BaseResourceStore();
+  it('errors on getRepresentation.', async(): Promise<void> => {
+    await expect(store.getRepresentation(any, any)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('errors on addResource.', async(): Promise<void> => {
+    await expect(store.addResource(any, any)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('errors on setRepresentation.', async(): Promise<void> => {
+    await expect(store.setRepresentation(any, any)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('errors on deleteResource.', async(): Promise<void> => {
+    await expect(store.deleteResource(any, any)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('errors on modifyResource.', async(): Promise<void> => {
+    await expect(store.modifyResource(any, any)).rejects.toThrow(NotImplementedHttpError);
+  });
+});