mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-08-30 09:07:40 -05:00
refactor(api-tokens): share one HasPermission lookup across caldav, feeds and mcp
This commit is contained in:
+22
-21
@@ -233,34 +233,35 @@ func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasCaldavAccess checks whether the token has the caldav access permission.
|
||||
// HasPermission reports whether the token grants permission within group.
|
||||
// Both sides are canonicalised the same way CanDoAPIRoute does it, so a
|
||||
// hyphenated group slug stored on the token still matches.
|
||||
func (t *APIToken) HasPermission(group, permission string) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
group = canonicalAPITokenGroup(group)
|
||||
for storedGroup, perms := range t.APIPermissions {
|
||||
if canonicalAPITokenGroup(storedGroup) == group && slices.Contains(perms, permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *APIToken) HasCaldavAccess() bool {
|
||||
perms, has := t.APIPermissions["caldav"]
|
||||
if !has {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(perms, "access")
|
||||
return t.HasPermission("caldav", "access")
|
||||
}
|
||||
|
||||
// HasFeedsAccess checks whether the token has the feeds access permission.
|
||||
func (t *APIToken) HasFeedsAccess() bool {
|
||||
perms, has := t.APIPermissions["feeds"]
|
||||
if !has {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(perms, "access")
|
||||
return t.HasPermission("feeds", "access")
|
||||
}
|
||||
|
||||
// HasMCPAccess checks whether the token has the mcp access permission.
|
||||
// The MCP endpoint uses POST, GET, and DELETE on the same path (streamable-HTTP
|
||||
// transport), so CanDoAPIRoute can't gate it — the MCP entry handler calls
|
||||
// this directly after the middleware skips the route check.
|
||||
// HasMCPAccess is called inline by the MCP entry handler: the streamable-HTTP
|
||||
// transport uses POST, GET and DELETE on one path, which CanDoAPIRoute's exact
|
||||
// (method, path) match cannot express.
|
||||
func (t *APIToken) HasMCPAccess() bool {
|
||||
perms, has := t.APIPermissions["mcp"]
|
||||
if !has {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(perms, "access")
|
||||
return t.HasPermission("mcp", "access")
|
||||
}
|
||||
|
||||
// GetTokenFromTokenString returns the full token object from the original token string.
|
||||
|
||||
@@ -246,6 +246,24 @@ func TestAPIToken_HasMCPAccess(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIToken_HasPermission(t *testing.T) {
|
||||
t.Run("nil token", func(t *testing.T) {
|
||||
var token *APIToken
|
||||
assert.False(t, token.HasPermission("tasks", "read_all"))
|
||||
})
|
||||
t.Run("nil permissions", func(t *testing.T) {
|
||||
assert.False(t, (&APIToken{}).HasPermission("tasks", "read_all"))
|
||||
})
|
||||
t.Run("hyphenated group key is canonicalised", func(t *testing.T) {
|
||||
token := &APIToken{
|
||||
APIPermissions: APIPermissions{"time-entries": {"read_all"}},
|
||||
}
|
||||
assert.True(t, token.HasPermission("time_entries", "read_all"))
|
||||
assert.True(t, token.HasPermission("time-entries", "read_all"))
|
||||
assert.False(t, token.HasPermission("time_entries", "create"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIToken_GetTokenFromTokenString(t *testing.T) {
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
s := db.NewSession()
|
||||
|
||||
@@ -18,7 +18,6 @@ package mcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
)
|
||||
@@ -29,20 +28,8 @@ import (
|
||||
// the client sees a structured failure rather than a JSON-RPC protocol error.
|
||||
var ErrScopeDenied = errors.New("mcp: tool not authorized for this token")
|
||||
|
||||
// tokenAuthorizes returns true iff the token's APIPermissions map contains
|
||||
// op.Permission() under the given resource's scope group. This is the
|
||||
// (group, permission) lookup that gates both tools/list visibility and
|
||||
// tools/call invocation; it intentionally duplicates rather than shares
|
||||
// CanDoAPIRoute's logic because MCP doesn't have a path/method to match —
|
||||
// the registry already owns the (resource, op) → (group, permission) mapping.
|
||||
//
|
||||
// A nil token or nil APIPermissions returns false (slices.Contains on a nil
|
||||
// slice is also false, so the second case is naturally handled). Defensive
|
||||
// checks here keep the dispatcher's "fail closed" contract even if the entry
|
||||
// handler somehow forgets to attach a token.
|
||||
// tokenAuthorizes maps an (mcp resource, op) pair onto the (group, permission)
|
||||
// pair the API token model stores; a nil token denies.
|
||||
func tokenAuthorizes(token *models.APIToken, resourceName string, op Op) bool {
|
||||
if token == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(token.APIPermissions[resourceName], op.Permission())
|
||||
return token.HasPermission(resourceName, op.Permission())
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@ func TestTokenAuthorizes_NoGroup(t *testing.T) {
|
||||
assert.False(t, tokenAuthorizes(token, "projects", OpCreate))
|
||||
}
|
||||
|
||||
func TestTokenAuthorizes_HyphenatedGroupKey(t *testing.T) {
|
||||
// The frontend snake_cases payloads, so a hyphenated slug must still match.
|
||||
token := &models.APIToken{
|
||||
APIPermissions: models.APIPermissions{
|
||||
"time-entries": []string{"read_all"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, tokenAuthorizes(token, "time_entries", OpReadAll))
|
||||
assert.False(t, tokenAuthorizes(token, "time_entries", OpCreate))
|
||||
}
|
||||
|
||||
func TestTokenAuthorizes_NilPermissionsMap(t *testing.T) {
|
||||
// A token with nil APIPermissions should never authorize anything.
|
||||
token := &models.APIToken{APIPermissions: nil}
|
||||
|
||||
Reference in New Issue
Block a user