Add grant model and shared permission-resolution algorithm (#51)
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m21s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m21s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
The heart of the security model: one algorithm, implemented once, for API, collab, and UI (permissions.md — authoritative). - shared `permissions/`: pure resolution (`resolvePageCapability`) exactly per permissions.md — specificity page > label (incl. ancestor labels) > pond, deny wins within a level, default-closed, Site Admin bypass — plus the trash rule (`canAccessPage` / `canAccessTrashedPage`, ADR 0013). `grantValidationError` enforces the structural constraints. Documented, I/O-free signatures for API/collab reuse. - prisma: `RoleGrant` (+ grant enums) per data-model.md, unique on (pond, subject, role, scope); migration adds a CHECK backstop that a POND_ADMIN grant is pond-scope + user-subject. - api `grants/`: `GrantsService.createGrant` validates before insert (structural + no extra admin on a personal pond), rejects duplicates; `grantsForPond` returns the shared resolver model (what #52/#53 consume); enum mappers between the DB and the shared model. Interim "who may manage grants" stays until #52. - tests: exhaustive table-driven resolver suite — every worked example from permissions.md §Resolution, edge cases (multi-label deny-wins, ancestor inheritance, anonymous/public, most-specific-allow-beats-less-specific-deny, trash) and a property test (a less-specific grant never overrides a more-specific decision); validation unit tests; grants db test proving write-time rejection of invalid grants. - i18n: grant error codes (de + en). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
parent
19fb24c527
commit
4d48d72c40
@ -0,0 +1,45 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GrantSubjectType" AS ENUM ('USER', 'AUTHENTICATED', 'PUBLIC');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GrantRole" AS ENUM ('POND_ADMIN', 'EDITOR', 'READER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GrantScopeType" AS ENUM ('POND', 'LABEL', 'PAGE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GrantEffect" AS ENUM ('ALLOW', 'DENY');
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "page_content_cache_search_vector_idx";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "role_grants" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"pond_id" TEXT NOT NULL,
|
||||||
|
"subject_type" "GrantSubjectType" NOT NULL,
|
||||||
|
"subject_id" TEXT,
|
||||||
|
"role" "GrantRole" NOT NULL,
|
||||||
|
"scope_type" "GrantScopeType" NOT NULL,
|
||||||
|
"scope_id" TEXT,
|
||||||
|
"effect" "GrantEffect" NOT NULL,
|
||||||
|
"created_by" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "role_grants_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "role_grants_pond_id_idx" ON "role_grants"("pond_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "role_grants_pond_id_subject_type_subject_id_role_scope_type_key" ON "role_grants"("pond_id", "subject_type", "subject_id", "role", "scope_type", "scope_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "role_grants" ADD CONSTRAINT "role_grants_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Structural backstop (permissions.md, issue #51): a POND_ADMIN grant is only
|
||||||
|
-- valid at pond scope for a specific user. The personal-pond single-admin rule
|
||||||
|
-- needs the pond type and is enforced in the service, not here.
|
||||||
|
ALTER TABLE "role_grants" ADD CONSTRAINT "role_grants_pond_admin_scope_check"
|
||||||
|
CHECK ("role" <> 'POND_ADMIN' OR ("scope_type" = 'POND' AND "subject_type" = 'USER'));
|
||||||
@ -79,11 +79,62 @@ model Pond {
|
|||||||
pages Page[]
|
pages Page[]
|
||||||
attachments Attachment[]
|
attachments Attachment[]
|
||||||
labels Label[]
|
labels Label[]
|
||||||
|
grants RoleGrant[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@map("ponds")
|
@@map("ponds")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GrantSubjectType {
|
||||||
|
USER
|
||||||
|
AUTHENTICATED
|
||||||
|
PUBLIC
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GrantRole {
|
||||||
|
POND_ADMIN
|
||||||
|
EDITOR
|
||||||
|
READER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GrantScopeType {
|
||||||
|
POND
|
||||||
|
LABEL
|
||||||
|
PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GrantEffect {
|
||||||
|
ALLOW
|
||||||
|
DENY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The permission table (permissions.md, data-model.md §role_grants, issue #51).
|
||||||
|
/// One grant `(subject, role, scope, effect)` inside a pond. The shared
|
||||||
|
/// resolution algorithm (`@dorfteich/shared` permissions) decides access from
|
||||||
|
/// these; the API/collab enforce it. Structural rules: `POND_ADMIN` only at
|
||||||
|
/// `POND` scope with a `USER` subject (a CHECK constraint backs this up, added
|
||||||
|
/// in the migration); personal ponds allow only their owner as admin (enforced
|
||||||
|
/// in the service, needs the pond type). `subjectId` is set only for `USER`
|
||||||
|
/// subjects; `scopeId` is the label/page id for `LABEL`/`PAGE` scopes.
|
||||||
|
model RoleGrant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
pondId String @map("pond_id")
|
||||||
|
subjectType GrantSubjectType @map("subject_type")
|
||||||
|
subjectId String? @map("subject_id")
|
||||||
|
role GrantRole
|
||||||
|
scopeType GrantScopeType @map("scope_type")
|
||||||
|
scopeId String? @map("scope_id")
|
||||||
|
effect GrantEffect
|
||||||
|
createdBy String @map("created_by")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
pond Pond @relation(fields: [pondId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([pondId, subjectType, subjectId, role, scopeType, scopeId])
|
||||||
|
@@index([pondId])
|
||||||
|
@@map("role_grants")
|
||||||
|
}
|
||||||
|
|
||||||
/// A wiki page (data-model.md §pages). Carries a Yjs document from day one
|
/// A wiki page (data-model.md §pages). Carries a Yjs document from day one
|
||||||
/// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is
|
/// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is
|
||||||
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
|
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { CompactionModule } from './compaction/compaction.module';
|
|||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig } from './config/app-config.service';
|
||||||
import { ConfigModule } from './config/config.module';
|
import { ConfigModule } from './config/config.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
|
import { GrantsModule } from './grants/grants.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { LabelsModule } from './labels/labels.module';
|
import { LabelsModule } from './labels/labels.module';
|
||||||
import { LinksModule } from './links/links.module';
|
import { LinksModule } from './links/links.module';
|
||||||
@ -40,6 +41,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
LabelsModule,
|
LabelsModule,
|
||||||
LinksModule,
|
LinksModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
GrantsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
|
|||||||
79
apps/api/src/grants/grant-mappers.ts
Normal file
79
apps/api/src/grants/grant-mappers.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Grant, GrantEffect, GrantRole, GrantScopeType, GrantSubjectType } from '@dorfteich/shared';
|
||||||
|
import {
|
||||||
|
GrantEffect as PrismaEffect,
|
||||||
|
GrantRole as PrismaRole,
|
||||||
|
GrantScopeType as PrismaScopeType,
|
||||||
|
GrantSubjectType as PrismaSubjectType,
|
||||||
|
RoleGrant,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps between the database's uppercase grant enums and the shared model's
|
||||||
|
* lowercase strings (issue #51), so the resolution algorithm in
|
||||||
|
* `@dorfteich/shared` sees one canonical shape regardless of storage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUBJECT_TO_DB: Record<GrantSubjectType, PrismaSubjectType> = {
|
||||||
|
user: 'USER',
|
||||||
|
authenticated: 'AUTHENTICATED',
|
||||||
|
public: 'PUBLIC',
|
||||||
|
};
|
||||||
|
const ROLE_TO_DB: Record<GrantRole, PrismaRole> = {
|
||||||
|
pond_admin: 'POND_ADMIN',
|
||||||
|
editor: 'EDITOR',
|
||||||
|
reader: 'READER',
|
||||||
|
};
|
||||||
|
const SCOPE_TO_DB: Record<GrantScopeType, PrismaScopeType> = {
|
||||||
|
pond: 'POND',
|
||||||
|
label: 'LABEL',
|
||||||
|
page: 'PAGE',
|
||||||
|
};
|
||||||
|
const EFFECT_TO_DB: Record<GrantEffect, PrismaEffect> = { allow: 'ALLOW', deny: 'DENY' };
|
||||||
|
|
||||||
|
const SUBJECT_FROM_DB: Record<PrismaSubjectType, GrantSubjectType> = {
|
||||||
|
USER: 'user',
|
||||||
|
AUTHENTICATED: 'authenticated',
|
||||||
|
PUBLIC: 'public',
|
||||||
|
};
|
||||||
|
const ROLE_FROM_DB: Record<PrismaRole, GrantRole> = {
|
||||||
|
POND_ADMIN: 'pond_admin',
|
||||||
|
EDITOR: 'editor',
|
||||||
|
READER: 'reader',
|
||||||
|
};
|
||||||
|
const SCOPE_FROM_DB: Record<PrismaScopeType, GrantScopeType> = {
|
||||||
|
POND: 'pond',
|
||||||
|
LABEL: 'label',
|
||||||
|
PAGE: 'page',
|
||||||
|
};
|
||||||
|
const EFFECT_FROM_DB: Record<PrismaEffect, GrantEffect> = { ALLOW: 'allow', DENY: 'deny' };
|
||||||
|
|
||||||
|
/** A stored grant row → the shared model shape. */
|
||||||
|
export function toGrant(row: RoleGrant): Grant {
|
||||||
|
return {
|
||||||
|
subjectType: SUBJECT_FROM_DB[row.subjectType],
|
||||||
|
subjectId: row.subjectId,
|
||||||
|
role: ROLE_FROM_DB[row.role],
|
||||||
|
scopeType: SCOPE_FROM_DB[row.scopeType],
|
||||||
|
scopeId: row.scopeId,
|
||||||
|
effect: EFFECT_FROM_DB[row.effect],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared grant shape → the Prisma enum columns (for writes). */
|
||||||
|
export function toGrantColumns(grant: Grant): {
|
||||||
|
subjectType: PrismaSubjectType;
|
||||||
|
subjectId: string | null;
|
||||||
|
role: PrismaRole;
|
||||||
|
scopeType: PrismaScopeType;
|
||||||
|
scopeId: string | null;
|
||||||
|
effect: PrismaEffect;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
subjectType: SUBJECT_TO_DB[grant.subjectType],
|
||||||
|
subjectId: grant.subjectId,
|
||||||
|
role: ROLE_TO_DB[grant.role],
|
||||||
|
scopeType: SCOPE_TO_DB[grant.scopeType],
|
||||||
|
scopeId: grant.scopeId,
|
||||||
|
effect: EFFECT_TO_DB[grant.effect],
|
||||||
|
};
|
||||||
|
}
|
||||||
17
apps/api/src/grants/grants.module.ts
Normal file
17
apps/api/src/grants/grants.module.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
|
|
||||||
|
import { GrantsService } from './grants.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission grants (issue #51). Exports `GrantsService` so the API guards and
|
||||||
|
* collab token issuance (#52/#53) can resolve grants; the member-management
|
||||||
|
* controller/UI arrives with #54.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [PondsModule],
|
||||||
|
providers: [GrantsService],
|
||||||
|
exports: [GrantsService],
|
||||||
|
})
|
||||||
|
export class GrantsModule {}
|
||||||
98
apps/api/src/grants/grants.service.db.test.ts
Normal file
98
apps/api/src/grants/grants.service.db.test.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { BadRequestException, ConflictException, INestApplication } from '@nestjs/common';
|
||||||
|
import { Grant } from '@dorfteich/shared';
|
||||||
|
import { PrismaClient, User } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { GrantsService } from './grants.service';
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('GrantsService (db, issue #51)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let grants: GrantsService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
let owner: User;
|
||||||
|
let shared: string;
|
||||||
|
let personal: string;
|
||||||
|
|
||||||
|
const editorGrant: Grant = {
|
||||||
|
subjectType: 'user',
|
||||||
|
subjectId: 'target-user',
|
||||||
|
role: 'editor',
|
||||||
|
scopeType: 'pond',
|
||||||
|
scopeId: null,
|
||||||
|
effect: 'allow',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
grants = app.get(GrantsService);
|
||||||
|
|
||||||
|
owner = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `grant-owner-${suffix}`,
|
||||||
|
email: `grant-owner-${suffix}@example.test`,
|
||||||
|
displayName: 'Grant Owner',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const s = await prisma.pond.create({
|
||||||
|
data: { slug: `grant-shared-${suffix}`, name: 'Shared', type: 'SHARED', ownerId: owner.id },
|
||||||
|
});
|
||||||
|
shared = s.id;
|
||||||
|
const p = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: `grant-personal-${suffix}`,
|
||||||
|
name: 'Personal',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
ownerId: owner.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
personal = p.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.roleGrant.deleteMany({ where: { pondId: { in: [shared, personal] } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: { in: [shared, personal] } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: owner.id } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a valid grant and reads it back through the resolver model', async () => {
|
||||||
|
const created = await grants.createGrant(owner, shared, editorGrant);
|
||||||
|
expect(created).toMatchObject({ role: 'editor', scopeType: 'pond', effect: 'allow' });
|
||||||
|
const all = await grants.grantsForPond(shared);
|
||||||
|
expect(all).toContainEqual(expect.objectContaining({ subjectId: 'target-user' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate grant', async () => {
|
||||||
|
await expect(grants.createGrant(owner, shared, editorGrant)).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a pond_admin grant at label scope (structural)', async () => {
|
||||||
|
const bad: Grant = {
|
||||||
|
...editorGrant,
|
||||||
|
role: 'pond_admin',
|
||||||
|
scopeType: 'label',
|
||||||
|
scopeId: 'some-label',
|
||||||
|
};
|
||||||
|
await expect(grants.createGrant(owner, shared, bad)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a second admin on a personal pond', async () => {
|
||||||
|
const admin: Grant = { ...editorGrant, role: 'pond_admin' };
|
||||||
|
await expect(grants.createGrant(owner, personal, admin)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pond_admin-scope rule also has a DB CHECK backstop (in the migration);
|
||||||
|
// it is not exercised here because the test database is built with `db push`,
|
||||||
|
// which syncs tables/columns but not the raw CHECK constraint.
|
||||||
|
});
|
||||||
83
apps/api/src/grants/grants.service.ts
Normal file
83
apps/api/src/grants/grants.service.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Grant, grantValidationError } from '@dorfteich/shared';
|
||||||
|
import { Pond, User } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { toGrant, toGrantColumns } from './grant-mappers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes and reads over the permission table (`role_grants`, issue #51). Grants
|
||||||
|
* are validated against the shared structural rules before insertion (the DB
|
||||||
|
* CHECK is a backstop for the pond_admin-scope rule). Who may manage grants is
|
||||||
|
* still the interim rule (owner/Site Admin) until #52 wires the real Pond Admin
|
||||||
|
* check; the member-management API/UI arrives with #54. `grantsForPond` is what
|
||||||
|
* the resolver (#52/#53) reads.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GrantsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly access: InterimAccessService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(GrantsService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All grants in a pond, as the shared resolver model (used by #52/#53). */
|
||||||
|
async grantsForPond(pondId: string): Promise<Grant[]> {
|
||||||
|
const rows = await this.prisma.roleGrant.findMany({ where: { pondId } });
|
||||||
|
return rows.map(toGrant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireModifiablePond(user: User, pondId: string): Promise<Pond> {
|
||||||
|
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
|
||||||
|
this.access.assertCanModify(user, pond);
|
||||||
|
return pond;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a grant after validating its structural constraints (issue #51):
|
||||||
|
* pond_admin only at pond scope for a user subject, and no extra admins on a
|
||||||
|
* personal pond. Rejects duplicates. Returns the stored grant.
|
||||||
|
*/
|
||||||
|
async createGrant(user: User, pondId: string, grant: Grant): Promise<Grant> {
|
||||||
|
const pond = await this.requireModifiablePond(user, pondId);
|
||||||
|
|
||||||
|
const invalid = grantValidationError(grant, {
|
||||||
|
pondType: pond.type === 'PERSONAL' ? 'personal' : 'shared',
|
||||||
|
});
|
||||||
|
if (invalid) throw new BadRequestException({ code: invalid });
|
||||||
|
|
||||||
|
const columns = toGrantColumns(grant);
|
||||||
|
const existing = await this.prisma.roleGrant.findFirst({
|
||||||
|
where: { pondId, ...columns },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) throw new ConflictException({ code: 'grant_exists' });
|
||||||
|
|
||||||
|
const created = await this.prisma.roleGrant.create({
|
||||||
|
data: { pondId, createdBy: user.id, ...columns },
|
||||||
|
});
|
||||||
|
this.logger.info(
|
||||||
|
{ grantId: created.id, pondId, userId: user.id, role: grant.role, scope: grant.scopeType },
|
||||||
|
'audit: grant created',
|
||||||
|
);
|
||||||
|
return toGrant(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a grant by id (member-management detail; permission is interim). */
|
||||||
|
async deleteGrant(user: User, grantId: string): Promise<void> {
|
||||||
|
const grant = await this.prisma.roleGrant.findUnique({ where: { id: grantId } });
|
||||||
|
if (!grant) throw new NotFoundException();
|
||||||
|
await this.requireModifiablePond(user, grant.pondId);
|
||||||
|
await this.prisma.roleGrant.delete({ where: { id: grantId } });
|
||||||
|
this.logger.info({ grantId, userId: user.id }, 'audit: grant deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,11 @@
|
|||||||
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
|
"unsupported_file_type": "Dieser Dateityp wird nicht unterstützt.",
|
||||||
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
|
"file_too_large": "Die Datei ist zu groß (Limit: {{limitBytes}} Bytes).",
|
||||||
"network": "Der Server war nicht erreichbar.",
|
"network": "Der Server war nicht erreichbar.",
|
||||||
|
"grant_exists": "Diese Berechtigung existiert bereits.",
|
||||||
|
"grant_pond_admin_scope": "Eine Teich-Admin-Berechtigung muss für den ganzen Teich und eine bestimmte Person gelten.",
|
||||||
|
"grant_pond_admin_personal_pond": "Der einzige Administrator eines persönlichen Teichs ist dessen Eigentümer.",
|
||||||
|
"grant_subject_id_mismatch": "Das Subjekt der Berechtigung ist widersprüchlich.",
|
||||||
|
"grant_scope_id_mismatch": "Der Geltungsbereich der Berechtigung ist widersprüchlich.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "Dieses Feld ist erforderlich.",
|
"required": "Dieses Feld ist erforderlich.",
|
||||||
"taken": "Dieser Wert ist bereits vergeben.",
|
"taken": "Dieser Wert ist bereits vergeben.",
|
||||||
|
|||||||
@ -31,6 +31,11 @@
|
|||||||
"unsupported_file_type": "This file type is not supported.",
|
"unsupported_file_type": "This file type is not supported.",
|
||||||
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
|
"file_too_large": "The file is too large (limit: {{limitBytes}} bytes).",
|
||||||
"network": "The server could not be reached.",
|
"network": "The server could not be reached.",
|
||||||
|
"grant_exists": "This grant already exists.",
|
||||||
|
"grant_pond_admin_scope": "A Pond Admin grant must apply to the whole pond and a specific user.",
|
||||||
|
"grant_pond_admin_personal_pond": "A personal pond's only administrator is its owner.",
|
||||||
|
"grant_subject_id_mismatch": "The grant's subject is inconsistent.",
|
||||||
|
"grant_scope_id_mismatch": "The grant's scope is inconsistent.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"required": "This field is required.",
|
"required": "This field is required.",
|
||||||
"taken": "This value is already taken.",
|
"taken": "This value is already taken.",
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export * from './i18n-tools';
|
|||||||
export * from './labels';
|
export * from './labels';
|
||||||
export * from './links';
|
export * from './links';
|
||||||
export * from './pages';
|
export * from './pages';
|
||||||
|
export * from './permissions';
|
||||||
export * from './search';
|
export * from './search';
|
||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
|
|||||||
3
packages/shared/src/permissions/index.ts
Normal file
3
packages/shared/src/permissions/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './resolve';
|
||||||
|
export * from './validate';
|
||||||
228
packages/shared/src/permissions/resolve.test.ts
Normal file
228
packages/shared/src/permissions/resolve.test.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { canAccessPage, canAccessTrashedPage, resolvePageCapability } from './resolve';
|
||||||
|
import type {
|
||||||
|
Grant,
|
||||||
|
GrantEffect,
|
||||||
|
GrantRole,
|
||||||
|
GrantScopeType,
|
||||||
|
PageResolutionContext,
|
||||||
|
PermissionViewer,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const UMA = 'uma';
|
||||||
|
const PAGE = 'salaries';
|
||||||
|
|
||||||
|
/** Concise grant builder. */
|
||||||
|
function grant(
|
||||||
|
role: GrantRole,
|
||||||
|
scopeType: GrantScopeType,
|
||||||
|
scopeId: string | null,
|
||||||
|
effect: GrantEffect,
|
||||||
|
subject: { type: 'user'; id: string } | { type: 'authenticated' } | { type: 'public' } = {
|
||||||
|
type: 'user',
|
||||||
|
id: UMA,
|
||||||
|
},
|
||||||
|
): Grant {
|
||||||
|
return {
|
||||||
|
subjectType: subject.type,
|
||||||
|
subjectId: subject.type === 'user' ? subject.id : null,
|
||||||
|
role,
|
||||||
|
scopeType,
|
||||||
|
scopeId,
|
||||||
|
effect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctx(
|
||||||
|
grants: Grant[],
|
||||||
|
options: {
|
||||||
|
viewer?: PermissionViewer;
|
||||||
|
pageLabelIds?: string[];
|
||||||
|
labelParents?: Record<string, string | null>;
|
||||||
|
} = {},
|
||||||
|
): PageResolutionContext {
|
||||||
|
return {
|
||||||
|
viewer: options.viewer ?? { userId: UMA, isSiteAdmin: false },
|
||||||
|
grants,
|
||||||
|
pageId: PAGE,
|
||||||
|
pageLabelIds: options.pageLabelIds ?? [],
|
||||||
|
labelParents: options.labelParents ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolvePageCapability — worked examples (permissions.md §Resolution)', () => {
|
||||||
|
it('pond-scope allow: Uma edits the page', () => {
|
||||||
|
const c = ctx([grant('editor', 'pond', null, 'allow')]);
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('label-scope deny (more specific than pond) blocks the edit', () => {
|
||||||
|
const c = ctx(
|
||||||
|
[grant('editor', 'pond', null, 'allow'), grant('editor', 'label', 'confidential', 'deny')],
|
||||||
|
{ pageLabelIds: ['confidential'] },
|
||||||
|
);
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page-scope allow (most specific) beats the label deny', () => {
|
||||||
|
const c = ctx(
|
||||||
|
[
|
||||||
|
grant('editor', 'pond', null, 'allow'),
|
||||||
|
grant('editor', 'label', 'confidential', 'deny'),
|
||||||
|
grant('editor', 'page', PAGE, 'allow'),
|
||||||
|
],
|
||||||
|
{ pageLabelIds: ['confidential'] },
|
||||||
|
);
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two labels at the same level: deny wins', () => {
|
||||||
|
const c = ctx(
|
||||||
|
[
|
||||||
|
grant('editor', 'pond', null, 'allow'),
|
||||||
|
grant('editor', 'label', 'confidential', 'deny'),
|
||||||
|
grant('editor', 'label', 'hr', 'allow'),
|
||||||
|
],
|
||||||
|
{ pageLabelIds: ['confidential', 'hr'] },
|
||||||
|
);
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolvePageCapability — edge cases', () => {
|
||||||
|
it('default-closed: no grants → deny', () => {
|
||||||
|
expect(resolvePageCapability('read', ctx([]))).toBe(false);
|
||||||
|
expect(resolvePageCapability('write', ctx([]))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Site Admin bypasses resolution', () => {
|
||||||
|
const c = ctx([grant('reader', 'pond', null, 'deny')], {
|
||||||
|
viewer: { userId: 'admin', isSiteAdmin: true },
|
||||||
|
});
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reader role covers read but not write', () => {
|
||||||
|
const c = ctx([grant('reader', 'pond', null, 'allow')]);
|
||||||
|
expect(resolvePageCapability('read', c)).toBe(true);
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('public subject: anonymous visitor may read a public-allow page', () => {
|
||||||
|
const anon: PermissionViewer = { userId: null, isSiteAdmin: false };
|
||||||
|
const c = ctx([grant('reader', 'pond', null, 'allow', { type: 'public' })], { viewer: anon });
|
||||||
|
expect(resolvePageCapability('read', c)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticated subject does not match an anonymous visitor', () => {
|
||||||
|
const anon: PermissionViewer = { userId: null, isSiteAdmin: false };
|
||||||
|
const c = ctx([grant('reader', 'pond', null, 'allow', { type: 'authenticated' })], {
|
||||||
|
viewer: anon,
|
||||||
|
});
|
||||||
|
expect(resolvePageCapability('read', c)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticated subject matches any logged-in user', () => {
|
||||||
|
const c = ctx([grant('reader', 'pond', null, 'allow', { type: 'authenticated' })], {
|
||||||
|
viewer: { userId: 'anyone', isSiteAdmin: false },
|
||||||
|
});
|
||||||
|
expect(resolvePageCapability('read', c)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a grant on an ancestor label inherits to a page tagged with a descendant', () => {
|
||||||
|
// parent → child; the page carries `child`, the grant is on `parent`.
|
||||||
|
const c = ctx([grant('editor', 'label', 'parent', 'allow')], {
|
||||||
|
pageLabelIds: ['child'],
|
||||||
|
labelParents: { parent: null, child: 'parent' },
|
||||||
|
});
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"only pages with label Y": no pond grant + label allow', () => {
|
||||||
|
const c = ctx([grant('editor', 'label', 'y', 'allow')], { pageLabelIds: ['y'] });
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(true);
|
||||||
|
// A page without label Y gets nothing.
|
||||||
|
expect(resolvePageCapability('write', ctx([grant('editor', 'label', 'y', 'allow')]))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"all except label X": pond allow + label deny', () => {
|
||||||
|
const grants = [grant('editor', 'pond', null, 'allow'), grant('editor', 'label', 'x', 'deny')];
|
||||||
|
// Page with X → denied.
|
||||||
|
expect(resolvePageCapability('write', ctx(grants, { pageLabelIds: ['x'] }))).toBe(false);
|
||||||
|
// Page without X → allowed.
|
||||||
|
expect(resolvePageCapability('write', ctx(grants, { pageLabelIds: ['other'] }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a deny for a different subject does not affect this user', () => {
|
||||||
|
const c = ctx([
|
||||||
|
grant('editor', 'pond', null, 'allow'),
|
||||||
|
grant('editor', 'pond', null, 'deny', { type: 'user', id: 'someone-else' }),
|
||||||
|
]);
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trash rule (permissions.md §trash, ADR 0013)', () => {
|
||||||
|
const editor = ctx([grant('editor', 'pond', null, 'allow')]);
|
||||||
|
|
||||||
|
it('a trashed page is not accessible in normal views', () => {
|
||||||
|
expect(canAccessPage('read', editor, true)).toBe(false);
|
||||||
|
expect(canAccessPage('write', editor, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a live page follows the base capability', () => {
|
||||||
|
expect(canAccessPage('write', editor, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Site Admin still sees trashed pages', () => {
|
||||||
|
const admin = ctx([], { viewer: { userId: 'a', isSiteAdmin: true } });
|
||||||
|
expect(canAccessPage('read', admin, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trash views/restore require write capability', () => {
|
||||||
|
expect(canAccessTrashedPage(editor)).toBe(true);
|
||||||
|
const reader = ctx([grant('reader', 'pond', null, 'allow')]);
|
||||||
|
expect(canAccessTrashedPage(reader)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('property: a less-specific grant never overrides a more-specific decision', () => {
|
||||||
|
const effects: GrantEffect[] = ['allow', 'deny'];
|
||||||
|
const lessSpecific: (Grant | null)[] = [
|
||||||
|
null,
|
||||||
|
grant('editor', 'label', 'l', 'allow'),
|
||||||
|
grant('editor', 'label', 'l', 'deny'),
|
||||||
|
grant('editor', 'pond', null, 'allow'),
|
||||||
|
grant('editor', 'pond', null, 'deny'),
|
||||||
|
];
|
||||||
|
|
||||||
|
it('a page-scope grant decides regardless of any label/pond grants', () => {
|
||||||
|
for (const pageEffect of effects) {
|
||||||
|
const expected = pageEffect === 'allow';
|
||||||
|
for (const extra of lessSpecific) {
|
||||||
|
const grants = [grant('editor', 'page', PAGE, pageEffect), ...(extra ? [extra] : [])];
|
||||||
|
const c = ctx(grants, { pageLabelIds: ['l'] });
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a label-scope grant decides regardless of any pond grants', () => {
|
||||||
|
const pondGrants: (Grant | null)[] = [
|
||||||
|
null,
|
||||||
|
grant('editor', 'pond', null, 'allow'),
|
||||||
|
grant('editor', 'pond', null, 'deny'),
|
||||||
|
];
|
||||||
|
for (const labelEffect of effects) {
|
||||||
|
const expected = labelEffect === 'allow';
|
||||||
|
for (const extra of pondGrants) {
|
||||||
|
const grants = [grant('editor', 'label', 'l', labelEffect), ...(extra ? [extra] : [])];
|
||||||
|
const c = ctx(grants, { pageLabelIds: ['l'] });
|
||||||
|
expect(resolvePageCapability('write', c)).toBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
109
packages/shared/src/permissions/resolve.ts
Normal file
109
packages/shared/src/permissions/resolve.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import type { Grant, PageResolutionContext, PermissionAction, PermissionViewer } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permission resolution algorithm (issue #51), implemented exactly as
|
||||||
|
* permissions.md §Resolution specifies and used by API, collab, and UI. Pure:
|
||||||
|
* inputs are grants + labels + page + viewer, output is a boolean decision.
|
||||||
|
*
|
||||||
|
* Specificity, highest first: **page > label (incl. ancestor labels) > pond**.
|
||||||
|
* The first level with any matching grant decides; within it, a single `deny`
|
||||||
|
* denies (deny wins ties), otherwise it allows. No matching grant at any level
|
||||||
|
* → deny (default-closed). Site Admins bypass everything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Does a grant's subject apply to the viewer? */
|
||||||
|
function subjectMatches(grant: Grant, viewer: PermissionViewer): boolean {
|
||||||
|
switch (grant.subjectType) {
|
||||||
|
case 'public':
|
||||||
|
return true;
|
||||||
|
case 'authenticated':
|
||||||
|
return viewer.userId !== null;
|
||||||
|
case 'user':
|
||||||
|
return viewer.userId !== null && viewer.userId === grant.subjectId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does a grant's role cover the action? Read is covered by any role; write
|
||||||
|
* needs editor or pond_admin. */
|
||||||
|
function roleCovers(grant: Grant, action: PermissionAction): boolean {
|
||||||
|
return action === 'read' || grant.role !== 'reader';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Within one specificity level: deny wins, otherwise (some allow) allows. */
|
||||||
|
function decide(levelGrants: Grant[]): boolean {
|
||||||
|
return !levelGrants.some((g) => g.effect === 'deny');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's "effective" label ids: its direct labels plus every ancestor of
|
||||||
|
* those labels — a grant on label L applies to L and all its descendants
|
||||||
|
* (permissions.md §label scope). Robust against malformed cycles.
|
||||||
|
*/
|
||||||
|
function effectiveLabelIds(ctx: PageResolutionContext): Set<string> {
|
||||||
|
const result = new Set<string>();
|
||||||
|
for (const start of ctx.pageLabelIds) {
|
||||||
|
let current: string | null | undefined = start;
|
||||||
|
while (current && !result.has(current)) {
|
||||||
|
result.add(current);
|
||||||
|
current = ctx.labelParents[current] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May the viewer perform `action` on the page, per the grants? This is the base
|
||||||
|
* capability — it does NOT apply the trash rule (see {@link canAccessPage} /
|
||||||
|
* {@link canAccessTrashedPage}). Site Admin → always true; default-closed.
|
||||||
|
*/
|
||||||
|
export function resolvePageCapability(
|
||||||
|
action: PermissionAction,
|
||||||
|
ctx: PageResolutionContext,
|
||||||
|
): boolean {
|
||||||
|
if (ctx.viewer.isSiteAdmin) return true;
|
||||||
|
|
||||||
|
const matching = ctx.grants.filter((g) => subjectMatches(g, ctx.viewer) && roleCovers(g, action));
|
||||||
|
|
||||||
|
// Page scope — grants on the page itself.
|
||||||
|
const pageGrants = matching.filter((g) => g.scopeType === 'page' && g.scopeId === ctx.pageId);
|
||||||
|
if (pageGrants.length > 0) return decide(pageGrants);
|
||||||
|
|
||||||
|
// Label scope — grants on any label assigned to the page or an ancestor.
|
||||||
|
const effective = effectiveLabelIds(ctx);
|
||||||
|
const labelGrants = matching.filter(
|
||||||
|
(g) => g.scopeType === 'label' && g.scopeId !== null && effective.has(g.scopeId),
|
||||||
|
);
|
||||||
|
if (labelGrants.length > 0) return decide(labelGrants);
|
||||||
|
|
||||||
|
// Pond scope — grants on the whole pond.
|
||||||
|
const pondGrants = matching.filter((g) => g.scopeType === 'pond');
|
||||||
|
if (pondGrants.length > 0) return decide(pondGrants);
|
||||||
|
|
||||||
|
// No matching grant at any level → deny.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May the viewer access the page in normal (non-trash) views for `action`
|
||||||
|
* (permissions.md §trash: a page or pond in trash is not accessible in normal
|
||||||
|
* views)? Site Admin bypasses. `trashed` = the page or its pond is in trash.
|
||||||
|
*/
|
||||||
|
export function canAccessPage(
|
||||||
|
action: PermissionAction,
|
||||||
|
ctx: PageResolutionContext,
|
||||||
|
trashed: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (ctx.viewer.isSiteAdmin) return true;
|
||||||
|
if (trashed) return false;
|
||||||
|
return resolvePageCapability(action, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May the viewer see or restore the page in trash views (permissions.md §trash,
|
||||||
|
* ADR 0013)? Only roles that could edit the page — write capability — may,
|
||||||
|
* whether or not it is currently trashed. Site Admin bypasses.
|
||||||
|
*/
|
||||||
|
export function canAccessTrashedPage(ctx: PageResolutionContext): boolean {
|
||||||
|
if (ctx.viewer.isSiteAdmin) return true;
|
||||||
|
return resolvePageCapability('write', ctx);
|
||||||
|
}
|
||||||
62
packages/shared/src/permissions/types.ts
Normal file
62
packages/shared/src/permissions/types.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Permission model types (issue #51, permissions.md — authoritative). One
|
||||||
|
* grant is `(subject, role, scope, effect)` inside one pond. The resolution
|
||||||
|
* algorithm ({@link ./resolve}) is implemented once here and consumed by the
|
||||||
|
* API guards, the collab token issuance, and the frontend (UI affordances only
|
||||||
|
* — the client never enforces security).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Actions the model decides. `write` covers create/edit/delete/history/trash. */
|
||||||
|
export type PermissionAction = 'read' | 'write';
|
||||||
|
|
||||||
|
/** A grant's role. `pond_admin` implies Editor everywhere in the pond. */
|
||||||
|
export type GrantRole = 'pond_admin' | 'editor' | 'reader';
|
||||||
|
|
||||||
|
/** `deny` expresses the vision's "all pages except label X". */
|
||||||
|
export type GrantEffect = 'allow' | 'deny';
|
||||||
|
|
||||||
|
/** Who a grant applies to. `authenticated` = any logged-in user; `public` =
|
||||||
|
* everyone including anonymous visitors. */
|
||||||
|
export type GrantSubjectType = 'user' | 'authenticated' | 'public';
|
||||||
|
|
||||||
|
/** What a grant covers. `pond_admin` grants exist only at `pond` scope. */
|
||||||
|
export type GrantScopeType = 'pond' | 'label' | 'page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single grant row (mirrors `role_grants`, data-model.md). `subjectId` is set
|
||||||
|
* only for `user` subjects; `scopeId` is the label/page id for `label`/`page`
|
||||||
|
* scopes and null for `pond` scope.
|
||||||
|
*/
|
||||||
|
export interface Grant {
|
||||||
|
subjectType: GrantSubjectType;
|
||||||
|
subjectId: string | null;
|
||||||
|
role: GrantRole;
|
||||||
|
scopeType: GrantScopeType;
|
||||||
|
scopeId: string | null;
|
||||||
|
effect: GrantEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The person (or anonymous visitor) whose access is being resolved. */
|
||||||
|
export interface PermissionViewer {
|
||||||
|
/** The user's id, or null for an anonymous visitor. */
|
||||||
|
userId: string | null;
|
||||||
|
/** Site Admins bypass resolution entirely (permissions.md). */
|
||||||
|
isSiteAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the pure resolver needs to decide access to one page — no I/O.
|
||||||
|
* The caller gathers the pond's grants, the page's directly-assigned labels,
|
||||||
|
* and the pond's label parent map (for ancestor inheritance).
|
||||||
|
*/
|
||||||
|
export interface PageResolutionContext {
|
||||||
|
viewer: PermissionViewer;
|
||||||
|
/** All grants in the page's pond. */
|
||||||
|
grants: Grant[];
|
||||||
|
/** The page whose access is being resolved. */
|
||||||
|
pageId: string;
|
||||||
|
/** Labels assigned directly to the page. */
|
||||||
|
pageLabelIds: string[];
|
||||||
|
/** Every label in the pond → its parent id (or null), for ancestor walks. */
|
||||||
|
labelParents: Record<string, string | null>;
|
||||||
|
}
|
||||||
63
packages/shared/src/permissions/validate.test.ts
Normal file
63
packages/shared/src/permissions/validate.test.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { Grant } from './types';
|
||||||
|
import { grantValidationError } from './validate';
|
||||||
|
|
||||||
|
const base: Grant = {
|
||||||
|
subjectType: 'user',
|
||||||
|
subjectId: 'u1',
|
||||||
|
role: 'editor',
|
||||||
|
scopeType: 'pond',
|
||||||
|
scopeId: null,
|
||||||
|
effect: 'allow',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('grantValidationError (issue #51)', () => {
|
||||||
|
it('accepts a valid editor grant', () => {
|
||||||
|
expect(grantValidationError(base, { pondType: 'shared' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a valid pond_admin grant on a shared pond', () => {
|
||||||
|
expect(
|
||||||
|
grantValidationError({ ...base, role: 'pond_admin' }, { pondType: 'shared' }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects pond_admin at label scope', () => {
|
||||||
|
const g: Grant = { ...base, role: 'pond_admin', scopeType: 'label', scopeId: 'l1' };
|
||||||
|
expect(grantValidationError(g, { pondType: 'shared' })).toBe('grant_pond_admin_scope');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects pond_admin with a non-user subject', () => {
|
||||||
|
const g: Grant = { ...base, role: 'pond_admin', subjectType: 'authenticated', subjectId: null };
|
||||||
|
expect(grantValidationError(g, { pondType: 'shared' })).toBe('grant_pond_admin_scope');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a second admin on a personal pond', () => {
|
||||||
|
const g: Grant = { ...base, role: 'pond_admin' };
|
||||||
|
expect(grantValidationError(g, { pondType: 'personal' })).toBe(
|
||||||
|
'grant_pond_admin_personal_pond',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a user subject without an id, and a non-user subject with one', () => {
|
||||||
|
expect(grantValidationError({ ...base, subjectId: null }, { pondType: 'shared' })).toBe(
|
||||||
|
'grant_subject_id_mismatch',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
grantValidationError(
|
||||||
|
{ ...base, subjectType: 'public', subjectId: 'x' },
|
||||||
|
{ pondType: 'shared' },
|
||||||
|
),
|
||||||
|
).toBe('grant_subject_id_mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a scoped grant without an id, and a pond grant with one', () => {
|
||||||
|
expect(
|
||||||
|
grantValidationError({ ...base, scopeType: 'label', scopeId: null }, { pondType: 'shared' }),
|
||||||
|
).toBe('grant_scope_id_mismatch');
|
||||||
|
expect(grantValidationError({ ...base, scopeId: 'p1' }, { pondType: 'shared' })).toBe(
|
||||||
|
'grant_scope_id_mismatch',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/shared/src/permissions/validate.ts
Normal file
49
packages/shared/src/permissions/validate.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { Grant } from './types';
|
||||||
|
|
||||||
|
/** Why a grant is invalid — a stable code the API maps to a localized message. */
|
||||||
|
export type GrantValidationError =
|
||||||
|
| 'grant_pond_admin_scope' // pond_admin must be pond-scope + user-subject
|
||||||
|
| 'grant_subject_id_mismatch' // user subjects need an id; others must not have one
|
||||||
|
| 'grant_scope_id_mismatch' // label/page scopes need an id; pond must not have one
|
||||||
|
| 'grant_pond_admin_personal_pond'; // personal ponds' single admin is the owner
|
||||||
|
|
||||||
|
/** The pond context a grant is validated against. */
|
||||||
|
export interface GrantValidationContext {
|
||||||
|
pondType: 'personal' | 'shared';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a grant's structural constraints (issue #51, permissions.md /
|
||||||
|
* data-model.md), returning the first violation or null when valid. Pure, so
|
||||||
|
* the API and any future caller reject invalid grants the same way. The DB also
|
||||||
|
* enforces the pond_admin-scope rule with a CHECK constraint as a backstop.
|
||||||
|
*/
|
||||||
|
export function grantValidationError(
|
||||||
|
grant: Grant,
|
||||||
|
context: GrantValidationContext,
|
||||||
|
): GrantValidationError | null {
|
||||||
|
// Subject id presence must match the subject type.
|
||||||
|
const hasSubjectId = grant.subjectId !== null && grant.subjectId !== undefined;
|
||||||
|
if ((grant.subjectType === 'user') !== hasSubjectId) {
|
||||||
|
return 'grant_subject_id_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope id presence must match the scope type.
|
||||||
|
const hasScopeId = grant.scopeId !== null && grant.scopeId !== undefined;
|
||||||
|
if ((grant.scopeType !== 'pond') !== hasScopeId) {
|
||||||
|
return 'grant_scope_id_mismatch';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grant.role === 'pond_admin') {
|
||||||
|
// Pond Admin exists only at pond scope for a specific user.
|
||||||
|
if (grant.scopeType !== 'pond' || grant.subjectType !== 'user') {
|
||||||
|
return 'grant_pond_admin_scope';
|
||||||
|
}
|
||||||
|
// Personal ponds have exactly one Pond Admin — the owner; no others.
|
||||||
|
if (context.pondType === 'personal') {
|
||||||
|
return 'grant_pond_admin_personal_pond';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user