Compare commits
1 Commits
main
...
claude/pro
| Author | SHA1 | Date |
|---|---|---|
|
|
64c3f464ac |
|
|
@ -48,5 +48,6 @@ devenv.local.nix
|
||||||
# AI Tools
|
# AI Tools
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
PLAN.md
|
PLAN.md
|
||||||
|
plans/
|
||||||
/.crush/
|
/.crush/
|
||||||
/.playwright-mcp
|
/.playwright-mcp
|
||||||
|
|
|
||||||
|
|
@ -212,11 +212,16 @@
|
||||||
"text1": "Are you sure you want to delete the token \"{token}\"?",
|
"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."
|
"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": {
|
"attributes": {
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"titlePlaceholder": "Enter a title you will recognize later",
|
"titlePlaceholder": "Enter a title you will recognize later",
|
||||||
"expiresAt": "Expires at",
|
"expiresAt": "Expires at",
|
||||||
"permissions": "Permissions"
|
"permissions": "Permissions",
|
||||||
|
"projectScope": "Project scope"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,7 @@ export interface IApiToken extends IAbstract {
|
||||||
token: string
|
token: string
|
||||||
permissions: IApiPermission
|
permissions: IApiPermission
|
||||||
expiresAt: Date
|
expiresAt: Date
|
||||||
|
projectId: number
|
||||||
|
includeSubProjects: boolean
|
||||||
created: Date
|
created: Date
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ export default class ApiTokenModel extends AbstractModel<IApiToken> {
|
||||||
token = ''
|
token = ''
|
||||||
permissions = null
|
permissions = null
|
||||||
expiresAt: Date = null
|
expiresAt: Date = null
|
||||||
|
projectId = 0
|
||||||
|
includeSubProjects = false
|
||||||
created: Date = null
|
created: Date = null
|
||||||
|
|
||||||
constructor(data: Partial<IApiToken> = {}) {
|
constructor(data: Partial<IApiToken> = {}) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import {useI18n} from 'vue-i18n'
|
||||||
import Message from '@/components/misc/Message.vue'
|
import Message from '@/components/misc/Message.vue'
|
||||||
import FormField from '@/components/input/FormField.vue'
|
import FormField from '@/components/input/FormField.vue'
|
||||||
import type {IApiToken} from '@/modelTypes/IApiToken'
|
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 service = new ApiTokenService()
|
||||||
const tokens = ref<IApiToken[]>([])
|
const tokens = ref<IApiToken[]>([])
|
||||||
|
|
@ -32,9 +35,14 @@ const newTokenPermissionValid = ref(true)
|
||||||
const apiTokenTitle = ref()
|
const apiTokenTitle = ref()
|
||||||
const tokenCreatedSuccessMessage = ref('')
|
const tokenCreatedSuccessMessage = ref('')
|
||||||
|
|
||||||
|
const selectedProject = ref<IProject | null>(null)
|
||||||
|
const includeSubProjects = ref(false)
|
||||||
|
|
||||||
const showDeleteModal = ref<boolean>(false)
|
const showDeleteModal = ref<boolean>(false)
|
||||||
const tokenToDelete = ref<IApiToken>()
|
const tokenToDelete = ref<IApiToken>()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
const {t} = useI18n()
|
const {t} = useI18n()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -158,11 +166,16 @@ async function createToken() {
|
||||||
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
|
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)
|
const token = await service.create(newToken.value)
|
||||||
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
|
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
|
||||||
newToken.value = new ApiTokenModel()
|
newToken.value = new ApiTokenModel()
|
||||||
newTokenExpiry.value = 30
|
newTokenExpiry.value = 30
|
||||||
newTokenExpiryCustom.value = new Date()
|
newTokenExpiryCustom.value = new Date()
|
||||||
|
selectedProject.value = null
|
||||||
|
includeSubProjects.value = false
|
||||||
resetPermissions()
|
resetPermissions()
|
||||||
tokens.value.push(token)
|
tokens.value.push(token)
|
||||||
showCreateForm.value = false
|
showCreateForm.value = false
|
||||||
|
|
@ -226,6 +239,7 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $t('misc.id') }}</th>
|
<th>{{ $t('misc.id') }}</th>
|
||||||
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</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.permissions') }}</th>
|
||||||
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
|
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
|
||||||
<th>{{ $t('misc.created') }}</th>
|
<th>{{ $t('misc.created') }}</th>
|
||||||
|
|
@ -241,6 +255,23 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
|
||||||
>
|
>
|
||||||
<td>{{ tk.id }}</td>
|
<td>{{ tk.id }}</td>
|
||||||
<td>{{ tk.title }}</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">
|
<td class="is-capitalized">
|
||||||
<template
|
<template
|
||||||
v-for="(v, p) in tk.permissions"
|
v-for="(v, p) in tk.permissions"
|
||||||
|
|
@ -330,6 +361,24 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Permissions -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
|
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,27 @@
|
||||||
owner_id: 2
|
owner_id: 2
|
||||||
created: 2023-09-01 07:00:00
|
created: 2023-09-01 07:00:00
|
||||||
# token in plaintext is tk_5e29ae2ae079781ff73b0a3e0fe4d75a0b8dcb7c
|
# 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"`
|
APIPermissions APIPermissions `xorm:"json not null permissions" json:"permissions" valid:"required"`
|
||||||
// The date when this key expires.
|
// The date when this key expires.
|
||||||
ExpiresAt time.Time `xorm:"not null" json:"expires_at" valid:"required"`
|
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.
|
// A timestamp when this api key was created. You cannot change this value.
|
||||||
Created time.Time `xorm:"created not null" json:"created"`
|
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
|
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)
|
_, err = s.Insert(t)
|
||||||
return err
|
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)
|
require.NoError(t, err)
|
||||||
tokens, is := result.([]*APIToken)
|
tokens, is := result.([]*APIToken)
|
||||||
assert.Truef(t, is, "tokens are not of type []*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.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(1), tokens[0].ID)
|
||||||
assert.Equal(t, int64(2), tokens[1].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) {
|
func TestAPIToken_CanDelete(t *testing.T) {
|
||||||
|
|
@ -93,6 +95,143 @@ func TestAPIToken_Create(t *testing.T) {
|
||||||
err := token.Create(s, u)
|
err := token.Create(s, u)
|
||||||
require.NoError(t, err)
|
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) {
|
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...)
|
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
|
return prs, resultCount, totalItems, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ func (p *Project) CanWrite(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
return false, nil
|
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
|
// Get the project and check the permission
|
||||||
originalProject, err := GetProjectSimpleByID(s, p.ID)
|
originalProject, err := GetProjectSimpleByID(s, p.ID)
|
||||||
if err != nil {
|
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)
|
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
|
// Check if the user is either owner or can read
|
||||||
var err error
|
var err error
|
||||||
originalProject, err := GetProjectSimpleByID(s, p.ID)
|
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
|
// CanCreate checks if the user can create a project
|
||||||
func (p *Project) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
|
func (p *Project) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
if p.ParentProjectID != 0 {
|
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}
|
parent := &Project{ID: p.ParentProjectID}
|
||||||
return parent.CanWrite(s, a)
|
return parent.CanWrite(s, a)
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +187,10 @@ func (p *Project) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
if is {
|
if is {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
// Project-scoped tokens cannot create top-level projects
|
||||||
|
if scope := GetProjectScope(a); scope != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +201,11 @@ func (p *Project) IsAdmin(s *xorm.Session, a web.Auth) (bool, error) {
|
||||||
return false, nil
|
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)
|
originalProject, err := GetProjectSimpleByID(s, p.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
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,
|
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
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,5 +99,13 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context) er
|
||||||
c.Set("api_token", token)
|
c.Set("api_token", token)
|
||||||
c.Set("api_user", u)
|
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
|
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
|
// GetFromAuth returns a user object from a web.Auth object and returns an error if the underlying type
|
||||||
// is not a user object
|
// is not a user object
|
||||||
func GetFromAuth(a web.Auth) (*User, error) {
|
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)
|
u, is := a.(*User)
|
||||||
if !is {
|
if !is {
|
||||||
typ := reflect.TypeOf(a)
|
typ := reflect.TypeOf(a)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,12 @@ type Auth interface {
|
||||||
GetID() int64
|
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
|
// Authprovider holds the implementation of an auth provider used by the application
|
||||||
type Authprovider interface {
|
type Authprovider interface {
|
||||||
GetAuthObject(echo.Context) (Auth, error)
|
GetAuthObject(echo.Context) (Auth, error)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue