From 7f13817248d5d418bbbeefb5a554070797146da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 15:01:22 +0000 Subject: [PATCH] docs: add plan for project-scoped API tokens https://claude.ai/code/session_01WEMXV5NvrdfDhyrByE9Vza --- plans/feat-project-scoped-api-tokens.md | 185 ++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 plans/feat-project-scoped-api-tokens.md diff --git a/plans/feat-project-scoped-api-tokens.md b/plans/feat-project-scoped-api-tokens.md new file mode 100644 index 000000000..b6d96acc2 --- /dev/null +++ b/plans/feat-project-scoped-api-tokens.md @@ -0,0 +1,185 @@ +# Project-Scoped API Tokens + +## Overview + +Add the ability to scope API tokens to a specific project (and optionally its sub-projects). When a token is project-scoped, it can only access resources within that project's scope — enforced through the existing permission system by carrying scope information on the User object. + +## Design + +### Core Idea + +Instead of adding a separate middleware layer for scope enforcement, we **extend the User struct** with transient scope fields. The token middleware sets these fields when a project-scoped token is used. The existing permission methods (`CanRead`, `CanWrite`, etc.) then type-assert to a `ProjectScoped` interface and reject access to out-of-scope resources. + +This means scope enforcement happens in exactly **two places**: +1. **Individual resources** — via existing permission methods (CanRead/CanWrite/CanCreate/CanDelete) +2. **Collection queries (ReadAll)** — via a `ProjectScopeable` interface that filters queries + +### Key Components + +#### 1. `ProjectScoped` Interface (`pkg/web/web.go`) + +```go +type ProjectScoped interface { + GetProjectScope() (projectID int64, includeSubProjects bool) +} +``` + +Defined alongside the existing `Auth` interface. Any auth object can optionally implement this. + +#### 2. User Struct Changes (`pkg/user/user.go`) + +Add transient (non-DB) fields: + +```go +APITokenProjectID int64 `xorm:"-" json:"-"` +APITokenIncludeSubProjects bool `xorm:"-" json:"-"` +``` + +Implement the `ProjectScoped` interface: + +```go +func (u *User) GetProjectScope() (int64, bool) { + return u.APITokenProjectID, u.APITokenIncludeSubProjects +} +``` + +#### 3. APIToken Model Changes (`pkg/models/api_tokens.go`) + +Add two fields + DB migration: + +```go +ProjectID int64 `xorm:"bigint null" json:"project_id"` +IncludeSubProjects bool `xorm:"bool default false" json:"include_sub_projects"` +``` + +Validation in `Create()`: if `ProjectID` is set, verify it exists and the token owner has at least read access to it. + +#### 4. Token Middleware Changes (`pkg/routes/api_tokens.go`) + +In `checkAPITokenAndPutItInContext`, after fetching the user: + +```go +u.APITokenProjectID = token.ProjectID +u.APITokenIncludeSubProjects = token.IncludeSubProjects +c.Set("api_user", u) +``` + +No other middleware changes needed. + +#### 5. Scope Helper Functions (`pkg/models/project_scope.go`) + +New file with: + +```go +// GetAllChildProjectIDs returns all descendant project IDs for a given project +func GetAllChildProjectIDs(s *xorm.Session, projectID int64) ([]int64, error) + +// IsProjectInScope checks if targetProjectID is within scope of scopeProjectID +// If includeSubProjects is true, checks all descendants; otherwise exact match only +func IsProjectInScope(s *xorm.Session, scopeProjectID int64, includeSubProjects bool, targetProjectID int64) (bool, error) + +// GetScopeProjectIDs returns the list of project IDs that are in scope. +// For exact match: returns [scopeProjectID] +// For sub-projects: returns [scopeProjectID, ...childIDs] +func GetScopeProjectIDs(s *xorm.Session, a web.Auth) (projectIDs []int64, hasScope bool, err error) +``` + +`GetAllChildProjectIDs` can use the existing parent_project_id relationships to walk the tree (iterative BFS or a recursive CTE query). + +#### 6. Permission Method Changes + +Add a scope check helper that can be called from permission methods: + +```go +// checkProjectScope checks if the given project is within the auth's project scope. +// Returns (true, nil) if there's no scope or the project is in scope. +// Returns (false, nil) if the project is out of scope. +func checkProjectScope(s *xorm.Session, a web.Auth, projectID int64) (bool, error) { + scoped, ok := a.(web.ProjectScoped) + if !ok { + return true, nil + } + scopeProjectID, includeSubProjects := scoped.GetProjectScope() + if scopeProjectID == 0 { + return true, nil + } + return IsProjectInScope(s, scopeProjectID, includeSubProjects, projectID) +} +``` + +Then add calls in: + +- **`Project.CanRead`** — after getting the project, call `checkProjectScope(s, a, p.ID)` +- **`Project.CanWrite`** — same pattern +- **`Project.CanCreate`** — check that the parent project (or the project itself for top-level) is in scope +- **`Project.CanDelete`** — same as CanWrite +- **`Task.CanRead`** — delegates to `Project.CanRead`, so handled automatically +- **`Task.CanWrite`** — delegates to `Project.CanWrite`, so handled automatically + +Other models that delegate to Project permissions (labels, comments, attachments, etc.) are handled transitively. + +#### 7. `ProjectScopeable` Interface for ReadAll + +For collection queries that build SQL rather than checking individual permissions: + +```go +type ProjectScopeable interface { + ApplyProjectScope(projectIDs []int64) +} +``` + +Implemented on: +- **`TaskCollection`** — adds `WHERE project_id IN (?)` to the task query +- **`Project` (ReadAll)** — filters the project list to only include in-scope projects + +The generic ReadAll handler (or the ReadAll methods themselves) checks if `web.Auth` implements `ProjectScoped`, resolves the scope to project IDs via `GetScopeProjectIDs()`, and calls `ApplyProjectScope()` before executing the query. + +#### 8. Database Migration + +New migration file adding two columns to the `api_tokens` table: + +```sql +ALTER TABLE api_tokens ADD COLUMN project_id BIGINT NULL; +ALTER TABLE api_tokens ADD COLUMN include_sub_projects TINYINT(1) DEFAULT 0; +``` + +#### 9. Frontend Changes + +Update the API token creation/edit UI to add: +- Project selector (dropdown/autocomplete) — optional field +- "Include sub-projects" checkbox — only shown when a project is selected +- Display the scoped project on the token list view + +Files to modify: +- `frontend/src/modelTypes/IApiToken.ts` — add `projectId` and `includeSubProjects` +- `frontend/src/components/user/Settings.vue` or equivalent token management component +- Translation strings in `frontend/src/i18n/lang/en.json` + +## Implementation Order + +1. Database migration (new columns on `api_tokens`) +2. `ProjectScoped` interface in `pkg/web/web.go` +3. User struct changes + `GetProjectScope()` implementation +4. APIToken model changes (new fields, validation) +5. Scope helper functions (`project_scope.go`) +6. `checkProjectScope` helper + permission method integration +7. Token middleware update (set scope fields on user) +8. `ProjectScopeable` for ReadAll on TaskCollection and Project +9. Tests (unit tests for scope helpers, integration tests for permission enforcement) +10. Frontend UI changes + +## What This Avoids + +- No separate middleware scope enforcement (no `ResolveProjectIDFromRequest`) +- No route path parsing for project IDs in middleware +- No duplicated permission logic +- Minimal changes to the web framework / generic CRUD handler + +## Edge Cases + +- **Top-level project creation**: Denied when token is project-scoped (can't create projects outside scope) +- **Moving tasks between projects**: CanWrite on the target project will enforce scope +- **Saved filters**: Should respect scope when resolving (the underlying task query will be scoped) +- **Favorites pseudo-project**: ReadAll on favorites should filter to scoped projects only +- **Token without project scope**: `ProjectID == 0` means no scope (current behavior, unchanged) +- **Deleted/inaccessible scope project**: Token becomes useless (all permission checks fail) — this is fine