feat: add project-scoped API tokens

Add optional project scope to API tokens, allowing tokens to be restricted
to a specific project and optionally its sub-projects. This builds on the
existing API token system by adding two new fields: project_id and
include_sub_projects.

Key changes:
- Database migration adding project_id and include_sub_projects columns
- ProjectScopedAuth wrapper type implementing web.Auth with scope info
- AuthUnwrapper interface for transparent auth type unwrapping
- Scope enforcement in project/task permission checks and list queries
- Middleware resolves scoped project IDs (with recursive CTE for sub-projects)
- Frontend: project selector in token creation form, scope display in list
- Tests for scope resolution, permission enforcement, and token creation

https://claude.ai/code/session_015JjPNeSkwxYQNCeMf2PYTi
This commit is contained in:
Claude 2026-03-22 11:28:40 +00:00
parent 1b246a0ff7
commit 64c3f464ac
No known key found for this signature in database
19 changed files with 621 additions and 4 deletions

1
.gitignore vendored
View File

@ -48,5 +48,6 @@ devenv.local.nix
# AI Tools
/.claude/settings.local.json
PLAN.md
plans/
/.crush/
/.playwright-mcp

View File

@ -212,11 +212,16 @@
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"projectScopeExplanation": "Optionally restrict this token to a specific project. Leave empty for access to all projects.",
"includeSubProjects": "Include sub-projects",
"allProjects": "All projects",
"withSubProjects": "+ sub-projects",
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
"permissions": "Permissions",
"projectScope": "Project scope"
}
},
"sessions": {

View File

@ -10,5 +10,7 @@ export interface IApiToken extends IAbstract {
token: string
permissions: IApiPermission
expiresAt: Date
projectId: number
includeSubProjects: boolean
created: Date
}

View File

@ -7,6 +7,8 @@ export default class ApiTokenModel extends AbstractModel<IApiToken> {
token = ''
permissions = null
expiresAt: Date = null
projectId = 0
includeSubProjects = false
created: Date = null
constructor(data: Partial<IApiToken> = {}) {

View File

@ -16,6 +16,9 @@ import {useI18n} from 'vue-i18n'
import Message from '@/components/misc/Message.vue'
import FormField from '@/components/input/FormField.vue'
import type {IApiToken} from '@/modelTypes/IApiToken'
import type {IProject} from '@/modelTypes/IProject'
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
import {useProjectStore} from '@/stores/projects'
const service = new ApiTokenService()
const tokens = ref<IApiToken[]>([])
@ -32,9 +35,14 @@ const newTokenPermissionValid = ref(true)
const apiTokenTitle = ref()
const tokenCreatedSuccessMessage = ref('')
const selectedProject = ref<IProject | null>(null)
const includeSubProjects = ref(false)
const showDeleteModal = ref<boolean>(false)
const tokenToDelete = ref<IApiToken>()
const projectStore = useProjectStore()
const {t} = useI18n()
const route = useRoute()
@ -158,11 +166,16 @@ async function createToken() {
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
}
newToken.value.projectId = selectedProject.value?.id || 0
newToken.value.includeSubProjects = selectedProject.value ? includeSubProjects.value : false
const token = await service.create(newToken.value)
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
newToken.value = new ApiTokenModel()
newTokenExpiry.value = 30
newTokenExpiryCustom.value = new Date()
selectedProject.value = null
includeSubProjects.value = false
resetPermissions()
tokens.value.push(token)
showCreateForm.value = false
@ -226,6 +239,7 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
<tr>
<th>{{ $t('misc.id') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.projectScope') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.permissions') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
<th>{{ $t('misc.created') }}</th>
@ -241,6 +255,23 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
>
<td>{{ tk.id }}</td>
<td>{{ tk.title }}</td>
<td>
<template v-if="tk.projectId">
{{ projectStore.projects[tk.projectId]?.title || `#${tk.projectId}` }}
<span
v-if="tk.includeSubProjects"
class="has-text-grey"
>
({{ $t('user.settings.apiTokens.withSubProjects') }})
</span>
</template>
<span
v-else
class="has-text-grey"
>
{{ $t('user.settings.apiTokens.allProjects') }}
</span>
</td>
<td class="is-capitalized">
<template
v-for="(v, p) in tk.permissions"
@ -330,6 +361,24 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
</div>
</div>
<!-- Project Scope -->
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.projectScope') }}</label>
<p class="mbe-2">
{{ $t('user.settings.apiTokens.projectScopeExplanation') }}
</p>
<ProjectSearch
v-model="selectedProject"
/>
<FancyCheckbox
v-if="selectedProject && selectedProject.id"
v-model="includeSubProjects"
class="mbs-2"
>
{{ $t('user.settings.apiTokens.includeSubProjects') }}
</FancyCheckbox>
</div>
<!-- Permissions -->
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>

View File

@ -28,3 +28,27 @@
owner_id: 2
created: 2023-09-01 07:00:00
# token in plaintext is tk_5e29ae2ae079781ff73b0a3e0fe4d75a0b8dcb7c
# Project-scoped token for user 1, scoped to project 1 (no sub-projects)
- id: 4
title: 'project scoped token'
token_salt: abcTokenSlt
token_hash: 4f87c7025e7b4847c6eeeb89b4c258e3a0c4e7e2b8f3d1a6c5b9e2d4f7a8b3c1e6d5f4a3b2c1d0e9f8a7b6c5d4e3f2a1
token_last_eight: aabbccdd
permissions: '{"tasks":["read_all","update"],"projects":["read_all","read_one"]}'
expires_at: 2099-01-01 00:00:00
owner_id: 1
project_id: 1
include_sub_projects: 0
created: 2023-09-01 07:00:00
# Project-scoped token for user 1, scoped to project 1 (with sub-projects)
- id: 5
title: 'project scoped token with sub-projects'
token_salt: defTokenSlt
token_hash: 5f87c7025e7b4847c6eeeb89b4c258e3a0c4e7e2b8f3d1a6c5b9e2d4f7a8b3c1e6d5f4a3b2c1d0e9f8a7b6c5d4e3f2a1
token_last_eight: ddeeffaa
permissions: '{"tasks":["read_all","update"],"projects":["read_all","read_one"]}'
expires_at: 2099-01-01 00:00:00
owner_id: 1
project_id: 1
include_sub_projects: 1
created: 2023-09-01 07:00:00

View File

@ -0,0 +1,44 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type apiTokens20260322120000 struct {
ProjectID int64 `xorm:"bigint null index" json:"project_id"`
IncludeSubProjects bool `xorm:"not null default false" json:"include_sub_projects"`
}
func (apiTokens20260322120000) TableName() string {
return "api_tokens"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260322120000",
Description: "Add project scope fields to api tokens",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(apiTokens20260322120000{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -48,6 +48,10 @@ type APIToken struct {
APIPermissions APIPermissions `xorm:"json not null permissions" json:"permissions" valid:"required"`
// The date when this key expires.
ExpiresAt time.Time `xorm:"not null" json:"expires_at" valid:"required"`
// When set, restricts this token to only access resources belonging to this project.
ProjectID int64 `xorm:"bigint null index" json:"project_id"`
// When true and ProjectID is set, the token also covers all descendant sub-projects.
IncludeSubProjects bool `xorm:"not null default false" json:"include_sub_projects"`
// A timestamp when this api key was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
@ -105,6 +109,22 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
if t.ProjectID != 0 {
// Verify the project exists and the token owner has at least read access
p := &Project{ID: t.ProjectID}
canRead, _, err := p.CanRead(s, a)
if err != nil {
return err
}
if !canRead {
return ErrProjectDoesNotExist{ID: t.ProjectID}
}
}
if t.IncludeSubProjects && t.ProjectID == 0 {
t.IncludeSubProjects = false
}
_, err = s.Insert(t)
return err
}

View File

@ -0,0 +1,70 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"xorm.io/xorm"
)
// GetProjectIDsForToken returns the list of project IDs a token is scoped to.
// Returns nil if the token has no project scope (user-wide token).
func GetProjectIDsForToken(s *xorm.Session, token *APIToken) ([]int64, error) {
if token.ProjectID == 0 {
return nil, nil
}
if !token.IncludeSubProjects {
return []int64{token.ProjectID}, nil
}
// Use recursive CTE to get all descendant project IDs
var descendantIDs []int64
err := s.SQL(
`WITH RECURSIVE descendant_ids (id) AS (
SELECT id
FROM projects
WHERE id = ?
UNION ALL
SELECT p.id
FROM projects p
INNER JOIN descendant_ids di ON p.parent_project_id = di.id
)
SELECT id FROM descendant_ids`,
token.ProjectID,
).Find(&descendantIDs)
if err != nil {
return nil, err
}
return descendantIDs, nil
}
// ProjectScopeContains checks if the given project ID is within the token's project scope.
// If scopedProjectIDs is nil, the token is unscoped and all projects are allowed.
func ProjectScopeContains(scopedProjectIDs []int64, projectID int64) bool {
if scopedProjectIDs == nil {
return true
}
for _, id := range scopedProjectIDs {
if id == projectID {
return true
}
}
return false
}

View File

@ -0,0 +1,134 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProjectScopeEnforcement(t *testing.T) {
t.Run("scoped auth can read project in scope", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{1}}
p := &Project{ID: 1}
can, _, err := p.CanRead(s, scoped)
require.NoError(t, err)
assert.True(t, can)
})
t.Run("scoped auth cannot read project outside scope", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
// User 1 owns project 1, but token is scoped to project 6 (which user 1 also has access to)
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{6}}
p := &Project{ID: 1}
can, _, err := p.CanRead(s, scoped)
require.NoError(t, err)
assert.False(t, can)
})
t.Run("scoped auth cannot write to project outside scope", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{6}}
p := &Project{ID: 1}
can, err := p.CanWrite(s, scoped)
require.NoError(t, err)
assert.False(t, can)
})
t.Run("scoped auth can write to project in scope", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{1}}
p := &Project{ID: 1}
can, err := p.CanWrite(s, scoped)
require.NoError(t, err)
assert.True(t, can)
})
t.Run("scoped auth cannot delete project outside scope", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{6}}
p := &Project{ID: 1}
can, err := p.IsAdmin(s, scoped)
require.NoError(t, err)
assert.False(t, can)
})
t.Run("unscoped auth can read any project it has access to", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
p := &Project{ID: 1}
can, _, err := p.CanRead(s, u)
require.NoError(t, err)
assert.True(t, can)
})
t.Run("scoped auth cannot create top-level projects", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{1}}
p := &Project{Title: "New Top Level"}
can, err := p.CanCreate(s, scoped)
require.NoError(t, err)
assert.False(t, can)
})
t.Run("scoped auth can create sub-project under scoped project", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{1}}
p := &Project{Title: "Sub Project", ParentProjectID: 1}
can, err := p.CanCreate(s, scoped)
require.NoError(t, err)
assert.True(t, can)
})
}

View File

@ -39,11 +39,13 @@ func TestAPIToken_ReadAll(t *testing.T) {
require.NoError(t, err)
tokens, is := result.([]*APIToken)
assert.Truef(t, is, "tokens are not of type []*APIToken")
assert.Len(t, tokens, 2)
assert.Len(t, tokens, 4)
assert.Len(t, tokens, count)
assert.Equal(t, int64(2), total)
assert.Equal(t, int64(4), total)
assert.Equal(t, int64(1), tokens[0].ID)
assert.Equal(t, int64(2), tokens[1].ID)
assert.Equal(t, int64(4), tokens[2].ID)
assert.Equal(t, int64(5), tokens[3].ID)
}
func TestAPIToken_CanDelete(t *testing.T) {
@ -93,6 +95,143 @@ func TestAPIToken_Create(t *testing.T) {
err := token.Create(s, u)
require.NoError(t, err)
})
t.Run("with project scope", func(t *testing.T) {
u := &user.User{ID: 1}
token := &APIToken{
ProjectID: 1,
}
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
err := token.Create(s, u)
require.NoError(t, err)
assert.Equal(t, int64(1), token.ProjectID)
assert.False(t, token.IncludeSubProjects)
})
t.Run("with project scope and sub-projects", func(t *testing.T) {
u := &user.User{ID: 1}
token := &APIToken{
ProjectID: 1,
IncludeSubProjects: true,
}
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
err := token.Create(s, u)
require.NoError(t, err)
assert.Equal(t, int64(1), token.ProjectID)
assert.True(t, token.IncludeSubProjects)
})
t.Run("with nonexistent project", func(t *testing.T) {
u := &user.User{ID: 1}
token := &APIToken{
ProjectID: 999999,
}
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
err := token.Create(s, u)
require.Error(t, err)
assert.True(t, IsErrProjectDoesNotExist(err))
})
t.Run("with project user has no access to", func(t *testing.T) {
u := &user.User{ID: 1}
token := &APIToken{
ProjectID: 2, // owned by user 3
}
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
err := token.Create(s, u)
require.Error(t, err)
assert.True(t, IsErrProjectDoesNotExist(err))
})
t.Run("include sub-projects without project ID is ignored", func(t *testing.T) {
u := &user.User{ID: 1}
token := &APIToken{
IncludeSubProjects: true,
}
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
err := token.Create(s, u)
require.NoError(t, err)
assert.False(t, token.IncludeSubProjects)
})
}
func TestGetProjectIDsForToken(t *testing.T) {
t.Run("no project scope", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
ids, err := GetProjectIDsForToken(s, &APIToken{ProjectID: 0})
require.NoError(t, err)
assert.Nil(t, ids)
})
t.Run("single project", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
ids, err := GetProjectIDsForToken(s, &APIToken{ProjectID: 1, IncludeSubProjects: false})
require.NoError(t, err)
assert.Equal(t, []int64{1}, ids)
})
t.Run("with sub-projects", func(t *testing.T) {
s := db.NewSession()
defer s.Close()
db.LoadAndAssertFixtures(t)
// Project 22 has child project 21
ids, err := GetProjectIDsForToken(s, &APIToken{ProjectID: 22, IncludeSubProjects: true})
require.NoError(t, err)
assert.Contains(t, ids, int64(22))
assert.Contains(t, ids, int64(21))
})
}
func TestProjectScopeContains(t *testing.T) {
t.Run("nil scope allows all", func(t *testing.T) {
assert.True(t, ProjectScopeContains(nil, 1))
assert.True(t, ProjectScopeContains(nil, 999))
})
t.Run("scope contains project", func(t *testing.T) {
assert.True(t, ProjectScopeContains([]int64{1, 2, 3}, 2))
})
t.Run("scope does not contain project", func(t *testing.T) {
assert.False(t, ProjectScopeContains([]int64{1, 2, 3}, 5))
})
}
func TestProjectScopedAuth(t *testing.T) {
t.Run("GetID delegates to inner auth", func(t *testing.T) {
u := &user.User{ID: 42}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{1}}
assert.Equal(t, int64(42), scoped.GetID())
})
t.Run("GetProjectScope returns scope", func(t *testing.T) {
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{1, 2}}
scope := GetProjectScope(scoped)
assert.Equal(t, []int64{1, 2}, scope)
})
t.Run("GetProjectScope returns nil for regular auth", func(t *testing.T) {
u := &user.User{ID: 1}
scope := GetProjectScope(u)
assert.Nil(t, scope)
})
t.Run("UnwrapAuth returns inner auth", func(t *testing.T) {
u := &user.User{ID: 1}
scoped := &ProjectScopedAuth{Auth: u, ProjectIDs: []int64{1}}
assert.Equal(t, u, scoped.UnwrapAuth())
})
}
func TestAPIToken_GetTokenFromTokenString(t *testing.T) {

View File

@ -262,6 +262,19 @@ func getAllRawProjects(s *xorm.Session, a web.Auth, search string, page int, per
prs = append(prs, savedFiltersProject...)
}
// Filter by project scope if using a project-scoped API token
if scope := GetProjectScope(a); scope != nil {
filtered := make([]*Project, 0, len(prs))
for _, pr := range prs {
if ProjectScopeContains(scope, pr.ID) {
filtered = append(filtered, pr)
}
}
prs = filtered
resultCount = len(prs)
totalItems = int64(len(prs))
}
return prs, resultCount, totalItems, err
}

View File

@ -34,6 +34,11 @@ func (p *Project) CanWrite(s *xorm.Session, a web.Auth) (bool, error) {
return false, nil
}
// Check project scope from API token
if scope := GetProjectScope(a); scope != nil && !ProjectScopeContains(scope, p.ID) {
return false, nil
}
// Get the project and check the permission
originalProject, err := GetProjectSimpleByID(s, p.ID)
if err != nil {
@ -92,6 +97,11 @@ func (p *Project) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) {
return sf.CanRead(s, a)
}
// Check project scope from API token
if scope := GetProjectScope(a); scope != nil && !ProjectScopeContains(scope, p.ID) {
return false, 0, nil
}
// Check if the user is either owner or can read
var err error
originalProject, err := GetProjectSimpleByID(s, p.ID)
@ -165,6 +175,10 @@ func (p *Project) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
// CanCreate checks if the user can create a project
func (p *Project) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
if p.ParentProjectID != 0 {
// Check project scope: parent must be in scope
if scope := GetProjectScope(a); scope != nil && !ProjectScopeContains(scope, p.ParentProjectID) {
return false, nil
}
parent := &Project{ID: p.ParentProjectID}
return parent.CanWrite(s, a)
}
@ -173,6 +187,10 @@ func (p *Project) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
if is {
return false, nil
}
// Project-scoped tokens cannot create top-level projects
if scope := GetProjectScope(a); scope != nil {
return false, nil
}
return true, nil
}
@ -183,6 +201,11 @@ func (p *Project) IsAdmin(s *xorm.Session, a web.Auth) (bool, error) {
return false, nil
}
// Check project scope from API token
if scope := GetProjectScope(a); scope != nil && !ProjectScopeContains(scope, p.ID) {
return false, nil
}
originalProject, err := GetProjectSimpleByID(s, p.ID)
if err != nil {
return false, err

View File

@ -0,0 +1,48 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/web"
)
// ProjectScopedAuth wraps a web.Auth and adds project scope restrictions
// from a project-scoped API token. Models can type-assert to this type
// to check and enforce project scope.
type ProjectScopedAuth struct {
Auth web.Auth
ProjectIDs []int64
}
// GetID implements the web.Auth interface by delegating to the wrapped auth.
func (p *ProjectScopedAuth) GetID() int64 {
return p.Auth.GetID()
}
// UnwrapAuth implements web.AuthUnwrapper to allow type assertions on the inner auth.
func (p *ProjectScopedAuth) UnwrapAuth() web.Auth {
return p.Auth
}
// GetProjectScope extracts project scope IDs from a web.Auth, if present.
// Returns nil if the auth is not project-scoped (meaning all projects are accessible).
func GetProjectScope(a web.Auth) []int64 {
if scoped, ok := a.(*ProjectScopedAuth); ok {
return scoped.ProjectIDs
}
return nil
}

View File

@ -185,7 +185,22 @@ func getRelevantProjectsFromCollection(s *xorm.Session, a web.Auth, tf *TaskColl
page: -1,
},
)
return projects, err
if err != nil {
return nil, err
}
// Filter by project scope if using a project-scoped API token
if scope := GetProjectScope(a); scope != nil {
filtered := make([]*Project, 0, len(projects))
for _, p := range projects {
if ProjectScopeContains(scope, p.ID) {
filtered = append(filtered, p)
}
}
projects = filtered
}
return projects, nil
}
// Check the project exists and the user has access on it

View File

@ -170,6 +170,15 @@ func GetAuthFromClaims(c *echo.Context) (a web.Auth, err error) {
if err != nil {
return nil, err
}
// Wrap with project scope if the token is project-scoped
if projectIDs, ok := c.Get("api_token_project_ids").([]int64); ok && len(projectIDs) > 0 {
return &models.ProjectScopedAuth{
Auth: u,
ProjectIDs: projectIDs,
}, nil
}
return u, nil
}

View File

@ -99,5 +99,13 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context) er
c.Set("api_token", token)
c.Set("api_user", u)
if token.ProjectID != 0 {
projectIDs, err := models.GetProjectIDsForToken(s, token)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error").Wrap(err)
}
c.Set("api_token_project_ids", projectIDs)
}
return nil
}

View File

@ -203,6 +203,11 @@ func (u *User) GetFailedPasswordAttemptsKey() string {
// GetFromAuth returns a user object from a web.Auth object and returns an error if the underlying type
// is not a user object
func GetFromAuth(a web.Auth) (*User, error) {
// Unwrap wrapper types (e.g. ProjectScopedAuth) to get the underlying auth
if unwrapper, ok := a.(web.AuthUnwrapper); ok {
a = unwrapper.UnwrapAuth()
}
u, is := a.(*User)
if !is {
typ := reflect.TypeOf(a)

View File

@ -61,6 +61,12 @@ type Auth interface {
GetID() int64
}
// AuthUnwrapper can be implemented by auth wrappers (e.g. project-scoped auth)
// to allow unwrapping to the underlying auth object.
type AuthUnwrapper interface {
UnwrapAuth() Auth
}
// Authprovider holds the implementation of an auth provider used by the application
type Authprovider interface {
GetAuthObject(echo.Context) (Auth, error)