fix(data-access): add guard for app actors

This commit is contained in:
KernelDeimos
2026-01-20 16:28:22 -05:00
committed by Eric Dubé
parent 25d23d687c
commit 1e3b704ea8
2 changed files with 81 additions and 0 deletions
@@ -494,6 +494,26 @@ export default class AppService extends BaseService {
return result.insertId;
}
async #check_app_owner_permission (old_app, actor) {
// Check if app has write permission to all user's apps
const svc_permission = this.services.get('permission');
const user = actor.type.user;
const perm = `es:app:${user.uuid}:write`;
const can_write_any = await svc_permission.check(actor, perm);
if ( can_write_any ) {
return;
}
// Otherwise verify the app owns this entity
const app = actor.type.app;
const app_owner = old_app.app_owner;
const app_owner_uid = app_owner?.uid;
if ( ! app_owner_uid || app_owner_uid !== app.uid ) {
throw APIError.create('forbidden');
}
}
async #update ({ object, id, options }) {
const old_app = await this.#read({
uid: object.uid,
@@ -515,6 +535,11 @@ export default class AppService extends BaseService {
// Check owner permission (WriteByOwnerOnlyES behavior)
await this.#check_owner_permission(old_app);
// If actor is AppUnderUserActorType, check app_owner (AppLimitedES behavior)
if ( actor.type instanceof AppUnderUserActorType ) {
await this.#check_app_owner_permission(old_app, actor);
}
// Remove protected/read_only fields from the update (ValidationES behavior)
{
object = { ...object };
@@ -1195,6 +1195,62 @@ describe('AppService', () => {
expect.arrayContaining(['https://mysite.puter.site'])
);
});
it('should throw forbidden when app actor does not own the entity (AppLimitedES behavior)', async () => {
// App actor trying to update an app it didn't create
setupContextForWrite(createMockAppUnderUserActor(1, 999));
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'different-app-uid',
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await expect(crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Hacked Title' },
})).rejects.toThrow();
});
it('should allow app actor to update entity it owns (AppLimitedES behavior)', async () => {
// App actor updating an app it created
const actor = createMockAppUnderUserActor(1, 100);
actor.type.app.uid = 'creator-app-uid';
setupContextForWrite(actor);
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'creator-app-uid',
})]);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Updated by App' },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Updated by App'])
);
});
it('should allow app actor with write permission to update any entity (AppLimitedES behavior)', async () => {
setupContextForWrite(createMockAppUnderUserActor(1, 999));
mockDb.read.mockResolvedValue([createMockAppRow({
owner_user_id: 1,
app_owner_uid: 'different-app-uid',
})]);
// Grant write permission
mockPermissionService.check.mockResolvedValue(true);
const crudQ = AppService.IMPLEMENTS['crud-q'];
await crudQ.update.call(appService, {
object: { uid: 'app-uid-123', title: 'Admin Update' },
});
expect(mockDbWrite.write).toHaveBeenCalledWith(
expect.stringContaining('UPDATE apps SET'),
expect.arrayContaining(['Admin Update'])
);
});
});
describe('#upsert', () => {