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:
parent
1b246a0ff7
commit
64c3f464ac
|
|
@ -48,5 +48,6 @@ devenv.local.nix
|
|||
# AI Tools
|
||||
/.claude/settings.local.json
|
||||
PLAN.md
|
||||
plans/
|
||||
/.crush/
|
||||
/.playwright-mcp
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@ export interface IApiToken extends IAbstract {
|
|||
token: string
|
||||
permissions: IApiPermission
|
||||
expiresAt: Date
|
||||
projectId: number
|
||||
includeSubProjects: boolean
|
||||
created: Date
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {}) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue