Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/wabe-documentation/docs/documentation/security/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ await run();

If your use is more specific, you can create your own `hook` to set the acl on the class.

### Important ACL default behavior

By default, objects with `acl: null` are considered shared:

- authenticated users can access them when CLP allows the operation;
- anonymous users can access them only when CLP allows anonymous access and ACL remains `null`.

If your expected model is owner-only access, you should always set ACL at creation time (for example with `permissions.acl` and `hookObject.addACL("users", ...)`), and clear role ACLs when needed.

To migrate an existing class safely:

1. Add a create hook (`permissions.acl`) that sets per-user ACL on every new object.
2. Backfill existing rows where `acl` is `null`.
3. Keep CLP as a class gate, and rely on ACL for per-object isolation.

Here is an example of a `hook` that can be created to set acl on the class `Company` before creation.

```ts
Expand Down
27 changes: 26 additions & 1 deletion packages/wabe/src/database/DatabaseController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { selectFieldsWithoutPrivateFields } from 'src/utils/helper'
import type { WabeTypes } from '../..'
import { initializeHook, OperationType } from '../hooks'
import { _checkCLP } from '../hooks/permissions'
import type { SchemaInterface } from '../schema'
import type { WabeContext } from '../server/interface'
import { isUnsafeObjectKey } from '../utils/objectKeys'
Expand Down Expand Up @@ -91,6 +92,27 @@ export class DatabaseController<T extends WabeTypes> {
return realClass?.fields[fieldName] as { type: string; class?: string } | undefined
}

/**
* _skipHooks is used by internal flows to avoid recursive hooks.
* We still enforce CLP for update operations to avoid bypassing class permissions.
*/
_assertCLPOnSkipHooksUpdate({
className,
context,
}: {
className: keyof T['types']
context: WabeContext<T>
}) {
_checkCLP(
{
className: String(className),
context,
getUser: () => context.user,
},
OperationType.BeforeUpdate,
)
}

_normalizeMutationPayload({
className,
context,
Expand Down Expand Up @@ -1316,6 +1338,7 @@ export class DatabaseController<T extends WabeTypes> {
_skipHooks,
}: UpdateObjectOptions<T, K, U, W>): Promise<OutputType<T, K, W>> {
if (_skipHooks) {
this._assertCLPOnSkipHooksUpdate({ className, context })
const whereWithACLCondition = this._buildWhereWithACL({}, context, 'write')
const normalizedData = this._normalizeMutationPayload({
className,
Expand Down Expand Up @@ -1404,6 +1427,8 @@ export class DatabaseController<T extends WabeTypes> {
payload: data,
})

if (_skipHooks) this._assertCLPOnSkipHooksUpdate({ className, context })

const hook = !_skipHooks
? initializeHook({
className,
Expand Down Expand Up @@ -1547,7 +1572,7 @@ export class DatabaseController<T extends WabeTypes> {
if (select && Object.keys(select).length > 0)
objectsBeforeDelete = await this.getObjects({
className,
where,
where: whereWithACLCondition,
select,
context,
first,
Expand Down
57 changes: 57 additions & 0 deletions packages/wabe/src/file/hookReadFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it, mock } from 'bun:test'
import { defaultAfterReadFile } from './hookReadFile'

describe('hookReadFile', () => {
it('should persist refreshed file URLs with an internal root context', async () => {
const updateObject = mock(async () => ({ id: 'object-id' }))
const readFile = mock(async (fileName: string) => `https://bucket.example/${fileName}`)

const hookObject = {
className: 'Test',
context: {
isRoot: false,
user: { id: 'user-id' },
wabe: {
config: {
schema: {
classes: [
{
name: 'Test',
fields: {
file: { type: 'File' },
},
},
],
},
file: { urlCacheInSeconds: 0 },
},
controllers: {
file: { readFile },
database: { updateObject },
},
},
},
object: {
id: 'object-id',
file: {
name: 'avatar.png',
},
},
}

await defaultAfterReadFile(hookObject as any)

expect(readFile).toHaveBeenCalledTimes(1)
expect(updateObject).toHaveBeenCalledTimes(1)

// @ts-expect-error - mock.calls is not typed
const updateInput = updateObject.mock.calls[0]?.[0] as any
expect(updateInput.className).toBe('Test')
expect(updateInput.id).toBe('object-id')
expect(updateInput._skipHooks).toBe(true)
expect(updateInput.context.isRoot).toBe(true)
expect(updateInput.context.user?.id).toBe('user-id')
expect(updateInput.data.file.url).toBe('https://bucket.example/avatar.png')
expect(new Date(updateInput.data.file.urlGeneratedAt)).toBeDate()
})
})
5 changes: 4 additions & 1 deletion packages/wabe/src/file/hookReadFile.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HookObject } from '../hooks/HookObject'
import { contextWithRoot } from '../utils/export'
import type { WabeFile } from './interface'

const isWabeFile = (value: unknown): value is WabeFile =>
Expand Down Expand Up @@ -59,7 +60,9 @@ const getFile = async (hookObject: HookObject<any, any>) => {

return hookObject.context.wabe.controllers.database.updateObject({
className: hookObject.className,
context: hookObject.context,
// Refreshing signed URLs is a technical side effect of reads.
// Use an internal context so we don't depend on end-user mutation rights.
context: contextWithRoot(hookObject.context),
id: hookObject.object.id,
data: {
[fieldName]: updatedFile,
Expand Down
5 changes: 4 additions & 1 deletion packages/wabe/src/graphql/pointerAndRelationFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export const add = async ({
})
const idsToAdd = add.map(getPointerId).filter(notEmpty)

if (typeOfExecution === 'create') {
if (idsToAdd.length > 0) {
const linkedObjects = await Promise.all(
idsToAdd.map((id) =>
context.wabe.controllers.database.getObject({
Expand All @@ -166,8 +166,11 @@ export const add = async ({
}),
),
)

if (linkedObjects.some((object) => !object)) throw new Error('Object not found')
}

if (typeOfExecution === 'create') {
return idsToAdd.map((id) =>
toPointerObject({
className: targetClass,
Expand Down
4 changes: 3 additions & 1 deletion packages/wabe/src/hooks/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { WabeContext } from '../server/interface'
import type { HookObject } from './HookObject'
import { OperationType } from './index'

type PermissionSubject = Pick<HookObject<any, any>, 'className' | 'context' | 'getUser'>

const convertOperationTypeToPermission = (operationType: OperationType) => {
const template: Record<OperationType, PermissionsOperations> = {
[OperationType.BeforeCreate]: 'create',
Expand Down Expand Up @@ -36,7 +38,7 @@ export const _getPermissionPropertiesOfAClass = ({
return permission
}

export const _checkCLP = (object: HookObject<any, any>, operationType: OperationType) => {
export const _checkCLP = (object: PermissionSubject, operationType: OperationType) => {
if (object.context.isRoot) return

const permissionOperation = convertOperationTypeToPermission(operationType)
Expand Down
Loading
Loading