Skip to content

Commit

Permalink
fix(server): blob controller permission (#6746)
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed Apr 30, 2024
1 parent 9b28e73 commit a14194c
Show file tree
Hide file tree
Showing 5 changed files with 295 additions and 9 deletions.
10 changes: 8 additions & 2 deletions packages/backend/server/src/core/workspaces/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ export class WorkspacesController {
) {
// if workspace is public or have any public page, then allow to access
// otherwise, check permission
if (!(await this.permission.tryCheckWorkspace(workspaceId, user?.id))) {
if (
!(await this.permission.isPublicAccessible(
workspaceId,
workspaceId,
user?.id
))
) {
throw new ForbiddenException('Permission denied');
}

Expand Down Expand Up @@ -81,7 +87,7 @@ export class WorkspacesController {
const docId = new DocID(guid, ws);
if (
// if a user has the permission
!(await this.permission.isAccessible(
!(await this.permission.isPublicAccessible(
docId.workspace,
docId.guid,
user?.id
Expand Down
6 changes: 5 additions & 1 deletion packages/backend/server/src/core/workspaces/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ export class PermissionService {
/**
* check if a doc binary is accessible by a user
*/
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
async isPublicAccessible(
ws: string,
id: string,
user?: string
): Promise<boolean> {
if (ws === id) {
// if workspace is public or have any public page, then allow to access
const [isPublicWorkspace, publicPages] = await Promise.all([
Expand Down
8 changes: 2 additions & 6 deletions packages/backend/server/tests/nestjs/throttler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
Throttle,
ThrottlerStorage,
} from '../../src/fundamentals/throttler';
import { createTestingApp, sessionCookie } from '../utils';
import { createTestingApp, internalSignIn } from '../utils';

const test = ava as TestFn<{
storage: ThrottlerStorage;
Expand Down Expand Up @@ -113,11 +113,7 @@ test.beforeEach(async t => {
const auth = app.get(AuthService);
const u1 = await auth.signUp('u1', 'u1@affine.pro', 'test');

const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: 'test' });

t.context.cookie = sessionCookie(res.headers)!;
t.context.cookie = await internalSignIn(app, u1.id);
});

test.afterEach.always(async t => {
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/server/tests/utils/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import {
import type { UserType } from '../../src/core/user';
import { gql } from './common';

export async function internalSignIn(app: INestApplication, userId: string) {
const auth = app.get(AuthService);

const session = await auth.createUserSession({ id: userId });

return `${AuthService.sessionCookieName}=${session.sessionId}`;
}

export function sessionCookie(headers: any): string {
const cookie = headers['set-cookie']?.find((c: string) =>
c.startsWith(`${AuthService.sessionCookieName}=`)
Expand Down
272 changes: 272 additions & 0 deletions packages/backend/server/tests/workspace/controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { Readable } from 'node:stream';

import { HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';

import { AppModule } from '../../src/app.module';
import { CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { DocHistoryManager, DocManager } from '../../src/core/doc';
import { WorkspaceBlobStorage } from '../../src/core/storage';
import { createTestingApp, internalSignIn } from '../utils';

const test = ava as TestFn<{
u1: CurrentUser;
db: PrismaClient;
app: INestApplication;
storage: Sinon.SinonStubbedInstance<WorkspaceBlobStorage>;
doc: Sinon.SinonStubbedInstance<DocManager>;
}>;

test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [AppModule],
tapModule: m => {
m.overrideProvider(WorkspaceBlobStorage)
.useValue(Sinon.createStubInstance(WorkspaceBlobStorage))
.overrideProvider(DocManager)
.useValue(Sinon.createStubInstance(DocManager))
.overrideProvider(DocHistoryManager)
.useValue(Sinon.createStubInstance(DocHistoryManager));
},
});

const auth = app.get(AuthService);
t.context.u1 = await auth.signUp('u1', 'u1@affine.pro', '1');
const db = app.get(PrismaClient);

t.context.db = db;
t.context.app = app;
t.context.storage = app.get(WorkspaceBlobStorage);
t.context.doc = app.get(DocManager);

await db.workspacePage.create({
data: {
workspace: {
create: {
id: 'public',
public: true,
},
},
pageId: 'private',
public: false,
},
});

await db.workspacePage.create({
data: {
workspace: {
create: {
id: 'private',
public: false,
},
},
pageId: 'public',
public: true,
},
});

await db.workspacePage.create({
data: {
workspace: {
create: {
id: 'totally-private',
public: false,
},
},
pageId: 'private',
public: false,
},
});
});

test.afterEach.always(async t => {
await t.context.app.close();
});

function blob() {
function stream() {
return Readable.from(Buffer.from('blob'));
}

const init = stream();
const ret = {
body: init,
metadata: {
contentType: 'text/plain',
lastModified: new Date(),
contentLength: 4,
},
};

init.on('end', () => {
ret.body = stream();
});

return ret;
}

// blob
test('should be able to get blob from public workspace', async t => {
const { app, u1, storage } = t.context;

// no authenticated user
storage.get.resolves(blob());
let res = await request(t.context.app.getHttpServer()).get(
'/api/workspaces/public/blobs/test'
);

t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');

// authenticated user
const cookie = await internalSignIn(app, u1.id);
res = await request(t.context.app.getHttpServer())
.get('/api/workspaces/public/blobs/test')
.set('Cookie', cookie);

t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');
});

test('should be able to get private workspace with public pages', async t => {
const { app, u1, storage } = t.context;

// no authenticated user
storage.get.resolves(blob());
let res = await request(app.getHttpServer()).get(
'/api/workspaces/private/blobs/test'
);

t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');

// authenticated user
const cookie = await internalSignIn(app, u1.id);
res = await request(app.getHttpServer())
.get('/api/workspaces/private/blobs/test')
.set('cookie', cookie);

t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');
});

test('should not be able to get private workspace with no public pages', async t => {
const { app, u1 } = t.context;

let res = await request(app.getHttpServer()).get(
'/api/workspaces/totally-private/blobs/test'
);

t.is(res.status, HttpStatus.FORBIDDEN);

res = await request(app.getHttpServer())
.get('/api/workspaces/totally-private/blobs/test')
.set('cookie', await internalSignIn(app, u1.id));

t.is(res.status, HttpStatus.FORBIDDEN);
});

test('should be able to get permission granted workspace', async t => {
const { app, u1, db, storage } = t.context;

const cookie = await internalSignIn(app, u1.id);
await db.workspaceUserPermission.create({
data: {
workspaceId: 'totally-private',
userId: u1.id,
type: 1,
accepted: true,
},
});

storage.get.resolves(blob());
const res = await request(app.getHttpServer())
.get('/api/workspaces/totally-private/blobs/test')
.set('Cookie', cookie);

t.is(res.status, HttpStatus.OK);
t.is(res.text, 'blob');
});

test('should return 404 if blob not found', async t => {
const { app, storage } = t.context;

// @ts-expect-error mock
storage.get.resolves({ body: null });
const res = await request(app.getHttpServer()).get(
'/api/workspaces/public/blobs/test'
);

t.is(res.status, HttpStatus.NOT_FOUND);
});

// doc
// NOTE: permission checking of doc api is the same with blob api, skip except one
test('should not be able to get private workspace with private page', async t => {
const { app, u1 } = t.context;

let res = await request(app.getHttpServer()).get(
'/api/workspaces/private/docs/private-page'
);

t.is(res.status, HttpStatus.FORBIDDEN);

res = await request(app.getHttpServer())
.get('/api/workspaces/private/docs/private-page')
.set('cookie', await internalSignIn(app, u1.id));

t.is(res.status, HttpStatus.FORBIDDEN);
});

test('should be able to get doc', async t => {
const { app, doc } = t.context;

doc.getBinary.resolves({
binary: Buffer.from([0, 0]),
timestamp: Date.now(),
});

const res = await request(app.getHttpServer()).get(
'/api/workspaces/private/docs/public'
);

t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'application/octet-stream');
t.deepEqual(res.body, Buffer.from([0, 0]));
});

test('should be able to change page publish mode', async t => {
const { app, doc, db } = t.context;

doc.getBinary.resolves({
binary: Buffer.from([0, 0]),
timestamp: Date.now(),
});

let res = await request(app.getHttpServer()).get(
'/api/workspaces/private/docs/public'
);

t.is(res.status, HttpStatus.OK);
t.is(res.get('publish-mode'), 'page');

await db.workspacePage.update({
where: { workspaceId_pageId: { workspaceId: 'private', pageId: 'public' } },
data: { mode: 1 },
});

res = await request(app.getHttpServer()).get(
'/api/workspaces/private/docs/public'
);

t.is(res.status, HttpStatus.OK);
t.is(res.get('publish-mode'), 'edgeless');
});

0 comments on commit a14194c

Please sign in to comment.