UNPKG

longcelot-sheet-db

Version:

Google Sheets-backed staging database adapter for Node.js with schema-first design

217 lines (167 loc) 7.35 kB
--- name: permissions description: Configure and use cross-actor access in longcelot-sheet-db. Use when one role needs to read or write another role's sheet (e.g., teacher accessing student data, admin auditing user sheets), when setting up a permission matrix in SheetAdapterConfig, when using asActor() to switch context, or when understanding how hasPermission() and resolveSpreadsheetId() enforce boundaries. license: MIT metadata: package: longcelot-sheet-db version: "0.1.15" --- # longcelot-sheet-db — Cross-Actor Permissions By default, every actor can only access their own sheet. Cross-actor access (e.g., a teacher writing to a student's sheet) requires two things: 1. A **permissions matrix** in `SheetAdapterConfig` 2. A **target context** (`targetActor` + `targetSheetId`) in `withContext()` Admin is exempt it bypasses all permission checks automatically. > `targetRole` (and the bare `role` field on `withContext()`) still work as deprecated aliases for `targetActor`/`actor` they emit a `console.warn` and are kept for backward compatibility. Use `actor`/`targetActor` in new code; see the `core` skill for why the field was renamed. --- ## 1. Configure the Permission Matrix ```typescript import { createSheetAdapter } from 'longcelot-sheet-db'; const adapter = createSheetAdapter({ adminSheetId: process.env.ADMIN_SHEET_ID!, credentials: { clientId, clientSecret, redirectUri }, tokens: oauthTokens, permissions: { teacher: { canAccess: ['student'], // roles this actor can access tables: ['scores', 'attendance'], // omit to allow all tables for this actor }, parent: { canAccess: ['student'], tables: ['scores'], // read-only enforced via allowedOperations (future) }, }, }); ``` ### ActorPermission type ```typescript interface ActorPermission { canAccess: string[]; // which actor roles can be accessed tables?: string[]; // optional: restrict to specific tables (omit = all) allowedOperations?: Array<'create' | 'read' | 'update' | 'delete'>; // planned } ``` --- ## 2. Create a Cross-Actor Context Pass `targetActor` and `targetSheetId` in `withContext()`: ```typescript const ctx = adapter.withContext({ userId: 'teacher_001', actor: 'teacher', actorSheetId: 'teacher-sheet-id', // teacher's own sheet targetActor: 'student', // the actor being accessed targetSheetId: 'student-sheet-id', // the specific student's sheet }); ``` Or use the `asActor()` shorthand on an existing context: ```typescript const teacherCtx = adapter.withContext({ userId: 'teacher_001', actor: 'teacher', actorSheetId: 'teacher-sheet-id', }); const crossCtx = teacherCtx.asActor('student', 'student-sheet-id'); ``` --- ## 3. Full CRUD Across Actor Sheets All four operations route to the **target** sheet once context is set: ```typescript // CREATE teacher writes a score into the student's sheet await crossCtx.table('scores').create({ student_id: 'student_123', subject: 'Mathematics', score: 95, graded_by: 'teacher_001', graded_at: new Date().toISOString(), }); // READ teacher reads scores from the student's sheet const scores = await crossCtx.table('scores').findMany({ where: { student_id: 'student_123' }, orderBy: 'graded_at', order: 'desc', }); // UPDATE teacher corrects a score await crossCtx.table('scores').update({ where: { _id: 'score_xyz' }, data: { score: 98, graded_at: new Date().toISOString() }, }); // DELETE teacher removes an erroneous entry await crossCtx.table('scores').delete({ where: { _id: 'score_xyz' } }); ``` --- ## 4. Admin Bypass Admin actors bypass the permission matrix entirely. No `targetActor`/`targetSheetId` configuration needed: ```typescript const adminCtx = adapter.withContext({ userId: 'admin_001', actor: 'admin', actorSheetId: process.env.ADMIN_SHEET_ID!, // optionally add targetSheetId to access a specific user sheet targetActor: 'student', targetSheetId: 'any-student-sheet-id', }); // Admin always has access await adminCtx.table('scores').findMany(); await adminCtx.table('profile').findMany(); ``` --- ## 5. Fetching `targetSheetId` at Runtime The target sheet ID must come from your admin `users` table, not be hardcoded: ```typescript // Look up the student's sheet ID first const adminCtx = adapter.withContext({ userId: 'teacher_001', actor: 'admin', actorSheetId: process.env.ADMIN_SHEET_ID!, }); const student = await adminCtx.table('users').findOne({ where: { user_id: 'student_123' }, }); // Then switch to cross-actor context const crossCtx = teacherCtx.asActor('student', student!.actor_sheet_id as string); ``` --- ## 6. Aggregating Data Across Multiple Sheets ```typescript async function getAllStudentScores(teacherId: string, teacherSheetId: string) { const teacherCtx = adapter.withContext({ userId: teacherId, actor: 'teacher', actorSheetId: teacherSheetId, }); // Get the teacher's student list const students = await teacherCtx.table('teacher_students').findMany({ where: { teacher_id: teacherId }, }); const allScores = []; for (const student of students) { const scores = await teacherCtx .asActor('student', student.actor_sheet_id as string) .table('scores') .findMany(); allScores.push(...scores.map((s) => ({ ...s, student_name: student.name, student_email: student.email, }))); } return allScores; } ``` --- ## 7. Permission Enforcement Rules | Scenario | Result | |---|---| | Same actor accessing own tables | Always allowed | | Admin actor | Always allowed (bypasses all checks) | | Cross-actor, no `permissions` configured for the caller | `PermissionError: 'teacher' has no cross-actor permissions configured` | | Cross-actor, actor not in `canAccess` list | `PermissionError: 'teacher' cannot access 'student' sheets` | | Cross-actor, table not in allowed `tables` list | `PermissionError: Table 'profile' is not allowed for 'teacher' 'student' access` | | `targetActor` set but `targetSheetId` missing | `PermissionError: targetSheetId required for cross-actor access` | | Non-admin accessing admin tables | `PermissionError` (always blocked) | --- ## Common Mistakes - **Hardcoding `targetSheetId`** Always fetch it from the admin `users` table at request time. Sheet IDs can change if users reconnect their accounts. - **Not registering the target table's schema** If `scores` is a `student` table, it must be registered via `adapter.registerSchemas()` even when accessed by a teacher context. - **Setting `targetActor` without `targetSheetId`** Both fields are required together. Missing `targetSheetId` always throws a `PermissionError`. - **Expecting `tables: []` to block all tables** An empty array means the key is present but empty; the runtime checks `perm.tables && !perm.tables.includes(schema.name)`. Pass `tables: ['allowedTable']` with at least one entry, or omit `tables` entirely to allow all tables for that `canAccess` pair. - **Admin context accessing non-admin tables** Admin always goes to `adminSheetId`. To access a user sheet as admin, you must still pass `targetSheetId` (admin bypasses permission checks but still needs the sheet ID to know where to read/write).