longcelot-sheet-db
Version:
Google Sheets-backed staging database adapter for Node.js with schema-first design
217 lines (167 loc) • 7.35 kB
Markdown
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).