Vision: Allow use of configured service key for API authentication #5299

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-30 10:02:16 +01:00
parent 5365e214ad
commit 42edf100ee
18 changed files with 240 additions and 36 deletions

View File

@@ -19,6 +19,7 @@ var (
CachePath = ""
ModelsPath = ""
DownloadUrl = ""
ServiceApi = false
ServiceUri = ""
ServiceKey = ""
ServiceTimeout = 10 * time.Minute

View File

@@ -3,6 +3,7 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
@@ -33,9 +34,20 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
// Disable response caching.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Allow requests based on an access token for specific resources.
switch resource {
case acl.ResourceVision:
if perms.Contains(acl.ActionUse) && vision.ServiceApi && vision.ServiceKey != "" && vision.ServiceKey == authToken {
s = entity.NewSessionFromToken(c, authToken, acl.ResourceVision.String(), "service-key")
event.AuditInfo([]string{clientIp, "%s", "%s %s as %s", status.Granted}, s.RefID, perms.First(), string(resource), s.GetClientRole().String())
return s
}
}
// Find active session to perform authorization check or deny if no session was found.
if s = Session(clientIp, authToken); s == nil {
if s = authAnyJWT(c, clientIp, authToken, resource, perms); s != nil {
event.AuditInfo([]string{clientIp, "session %s", "%s %s as %s", status.Granted}, s.RefID, perms.First(), string(resource), s.GetClientRole().String())
return s
}
event.AuditWarn([]string{clientIp, "%s %s without authentication", status.Denied}, perms.String(), string(resource))

View File

@@ -45,7 +45,7 @@ func TestAuthAnyJWT(t *testing.T) {
assert.Contains(t, session.AuthScope, "cluster")
assert.Equal(t, spec.Issuer, session.AuthIssuer)
assert.Equal(t, authn.MethodJWT.String(), session.AuthMethod)
assert.Equal(t, authn.ProviderClient.String(), session.AuthProvider)
assert.Equal(t, authn.ProviderAccessToken.String(), session.AuthProvider)
assert.Equal(t, authn.GrantJwtBearer.String(), session.GrantType)
assert.Equal(t, "192.0.2.10", session.ClientIP)
assert.Equal(t, "PhotoPrism Portal/1.0", session.UserAgent)

View File

@@ -11,12 +11,14 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/ai/vision"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/auth/session"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -146,6 +148,44 @@ func TestAuthToken(t *testing.T) {
})
}
func TestAuthAnyVisionServiceKey(t *testing.T) {
origAPI := vision.ServiceApi
origKey := vision.ServiceKey
defer func() {
vision.ServiceApi = origAPI
vision.ServiceKey = origKey
}()
vision.ServiceApi = true
vision.ServiceKey = "vision-service-key-abc123"
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodPost, "/api/v1/vision/labels", nil)
header.SetAuthorization(req, vision.ServiceKey)
req.RemoteAddr = "198.51.100.24:1234"
req.Header.Set(header.UserAgent, "VisionClient/1.0")
c.Request = req
s := AuthAny(c, acl.ResourceVision, acl.Permissions{acl.ActionUse})
require.NotNil(t, s)
assert.False(t, s.Abort(c))
assert.Equal(t, http.StatusOK, s.HttpStatus())
assert.Equal(t, vision.ServiceKey, s.AuthToken())
assert.Equal(t, rnd.SessionID(vision.ServiceKey), s.ID)
assert.Equal(t, acl.ResourceVision.String(), s.Scope())
assert.Equal(t, authn.GrantToken, s.GetGrantType())
assert.Equal(t, authn.ProviderAccessToken, s.GetProvider())
assert.Equal(t, authn.MethodDefault, s.GetMethod())
assert.Equal(t, header.ClientIP(c), s.ClientIP)
assert.Equal(t, req.UserAgent(), s.UserAgent)
assert.True(t, s.IsClient())
assert.Equal(t, acl.RoleClient, s.GetClientRole())
assert.EqualValues(t, 60, s.SessTimeout)
assert.True(t, rnd.IsRefID(s.RefID))
}
func TestAuthAnyPortalJWT(t *testing.T) {
fx := newPortalJWTFixture(t, "ok")

View File

@@ -42,6 +42,7 @@
"",
"cli",
"implicit",
"token",
"session",
"password",
"client_credentials",
@@ -57,6 +58,7 @@
"GrantUndefined",
"GrantCLI",
"GrantImplicit",
"GrantToken",
"GrantSession",
"GrantPassword",
"GrantClientCredentials",
@@ -2358,6 +2360,9 @@
"Role": {
"type": "string"
},
"Scope": {
"type": "string"
},
"Settings": {
"$ref": "#/definitions/entity.UserSettings"
},
@@ -3278,6 +3283,9 @@
"Role": {
"type": "string"
},
"Scope": {
"type": "string"
},
"SuperAdmin": {
"type": "boolean"
},

View File

@@ -1,5 +1,8 @@
package acl
// Any matches everything.
const Any = "*"
// RoleAliasNone is a more explicit, user-friendly alias for RoleNone.
const RoleAliasNone = "none"

View File

@@ -10,11 +10,6 @@ func (p Permission) String() string {
return strings.ReplaceAll(string(p), "_", " ")
}
// LogId returns an identifier string for use in log messages.
func (p Permission) LogId() string {
return p.String()
}
// Equal checks if the type matches.
func (p Permission) Equal(s string) bool {
return strings.EqualFold(s, p.String())

View File

@@ -6,30 +6,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestPermissions_String(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
perms := Permissions{}
assert.Equal(t, "", perms.String())
})
t.Run("FullAccess", func(t *testing.T) {
perms := Permissions{FullAccess}
assert.Equal(t, "full access", perms.String())
})
t.Run("ManageUploadAll", func(t *testing.T) {
perms := Permissions{ActionManage, ActionUpload, AccessAll}
assert.Equal(t, "manage, upload, access all", perms.String())
})
}
func TestPermission_LogId(t *testing.T) {
t.Run("FullAccess", func(t *testing.T) {
assert.Equal(t, "full access", FullAccess.LogId())
})
t.Run("ActionUpload", func(t *testing.T) {
assert.Equal(t, "upload", ActionUpload.LogId())
})
}
func TestPermission_Equal(t *testing.T) {
t.Run("True", func(t *testing.T) {
assert.True(t, FullAccess.Equal("full access"))

View File

@@ -15,3 +15,30 @@ func (perm Permissions) String() string {
return strings.Join(s, ", ")
}
// First returns the first permission as a string. When the slice is empty it defaults to ActionUse.
func (perm Permissions) First() string {
if len(perm) == 0 {
return ActionUse.String()
}
return perm[0].String()
}
// Contains reports whether the specified permission or wildcard is present in this set.
func (perm Permissions) Contains(p Permission) bool {
if len(perm) == 0 || p == "" {
return false
} else if p == Any {
return true
}
// Find matches.
for i := range perm {
if p == perm[i] || perm[i] == Any {
return true
}
}
return false
}

View File

@@ -0,0 +1,53 @@
package acl
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPermissions_String(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
perms := Permissions{}
assert.Equal(t, "", perms.String())
})
t.Run("FullAccess", func(t *testing.T) {
perms := Permissions{FullAccess}
assert.Equal(t, "full access", perms.String())
})
t.Run("ManageUploadAll", func(t *testing.T) {
perms := Permissions{ActionManage, ActionUpload, AccessAll}
assert.Equal(t, "manage, upload, access all", perms.String())
})
}
func TestPermissions_First(t *testing.T) {
t.Run("Default", func(t *testing.T) {
perms := Permissions{}
assert.Equal(t, ActionUse.String(), perms.First())
})
t.Run("Explicit", func(t *testing.T) {
perms := Permissions{ActionManage, ActionUpload}
assert.Equal(t, ActionManage.String(), perms.First())
})
}
func TestPermissions_Contains(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
perms := Permissions{}
assert.False(t, perms.Contains(ActionUse))
})
t.Run("ExactMatch", func(t *testing.T) {
perms := Permissions{ActionManage, ActionUpload}
assert.True(t, perms.Contains(ActionUpload))
assert.False(t, perms.Contains(ActionDelete))
})
t.Run("WildcardInSet", func(t *testing.T) {
perms := Permissions{Any}
assert.True(t, perms.Contains(ActionDelete))
})
t.Run("WildcardRequested", func(t *testing.T) {
perms := Permissions{ActionManage}
assert.True(t, perms.Contains(Any))
})
}

View File

@@ -2,7 +2,7 @@ package acl
// ScopeDescriptions maps supported authorization scopes to human-readable summaries for CLI help and docs.
var ScopeDescriptions = map[string]string{
"*": "Wildcard granting full access to every resource and permission.",
Any: "Wildcard granting full access to every resource and permission.",
ScopeRead.String(): "Read-only access across all resources (search, view, download).",
ScopeWrite.String(): "Write access across all resources (create, update, delete) without implicit read permissions.",
ResourceFiles.String(): "Full access to original and derived files on disk.",

View File

@@ -318,6 +318,7 @@ func (c *Config) Propagate() {
// Configure computer vision package.
vision.SetCachePath(c.CachePath())
vision.SetModelsPath(c.ModelsPath())
vision.ServiceApi = c.VisionApi()
vision.ServiceUri = c.VisionUri()
vision.ServiceKey = c.VisionKey()
vision.DownloadUrl = c.DownloadUrl()

View File

@@ -92,6 +92,50 @@ func NewSession(expiresIn, timeout int64) (sess *Session) {
return sess
}
// NewSessionFromToken creates a transient access-token session backed by the caller's bearer token.
// It copies scope, client metadata, and request context so service-key and API token flows can reuse
// the existing authorization path without persisting state.
func NewSessionFromToken(c *gin.Context, token, scope, refId string) *Session {
if token == "" {
return nil
}
// "vision-api"
if !rnd.IsRefID(refId) {
refId = rnd.AuthTokenID("key")
}
// Create new session
sess := &Session{
Status: http.StatusOK,
RefID: refId,
}
// Determine token string.
sess.SetAuthToken(token)
// Set scope/claims metadata.
sess.SetScope(scope)
sess.SetGrantType(authn.GrantToken)
sess.SetMethod(authn.MethodDefault)
sess.SetProvider(authn.ProviderAccessToken)
sess.SetClientIP(header.ClientIP(c))
sess.SetUserAgent(header.ClientUserAgent(c))
// Derive timestamps from JWT claims when available.
now := time.Now().UTC()
sess.CreatedAt = now
sess.UpdatedAt = now
sess.LastActive = now.Unix()
// Expires in 60 seconds.
sess.SetExpiresIn(60)
sess.SetTimeout(60)
return sess
}
// SessionStatusUnauthorized returns a session with status unauthorized (401).
func SessionStatusUnauthorized() *Session {
return &Session{Status: http.StatusUnauthorized}

View File

@@ -47,13 +47,11 @@ func NewSessionFromJWT(c *gin.Context, jwt *JWT) *Session {
sess.SetAuthToken(token)
// Set scope/claims metadata.
if jwt.Scope != "" {
sess.SetScope(jwt.Scope)
}
sess.SetScope(jwt.Scope)
sess.SetAuthID(jwt.ID, jwt.Issuer)
sess.SetGrantType(authn.GrantJwtBearer)
sess.SetMethod(authn.MethodJWT)
sess.SetProvider(authn.ProviderClient)
sess.SetProvider(authn.ProviderAccessToken)
sess.SetClientName(jwt.Subject)
sess.SetClientIP(header.ClientIP(c))
sess.SetUserAgent(header.ClientUserAgent(c))

View File

@@ -48,7 +48,7 @@ func TestNewSessionFromJWT(t *testing.T) {
assert.True(t, rnd.IsSessionID(sess.ID))
assert.Equal(t, authn.GrantJwtBearer.String(), sess.GrantType)
assert.Equal(t, authn.MethodJWT.String(), sess.AuthMethod)
assert.Equal(t, authn.ProviderClient.String(), sess.AuthProvider)
assert.Equal(t, authn.ProviderAccessToken.String(), sess.AuthProvider)
assert.Equal(t, "portal:cs5cpu17n6gj2qo5", sess.GetClientName())
assert.Equal(t, clean.Scope("cluster vision"), sess.AuthScope)
assert.Equal(t, "portal:cbaa0276-07d3-43ac-b420-25e2601b0ad4", sess.AuthIssuer)

View File

@@ -2,6 +2,7 @@ package entity
import (
"net/http"
"net/http/httptest"
"testing"
"time"
@@ -62,6 +63,41 @@ func TestNewSession(t *testing.T) {
})
}
func TestNewSessionFromToken(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("EmptyToken", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
assert.Nil(t, NewSessionFromToken(c, "", acl.ResourceVision.String(), "vision-api"))
})
t.Run("PopulatedSession", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodPost, "/api/v1/vision/labels", nil)
req.RemoteAddr = "198.51.100.42:8080"
req.Header.Set(header.UserAgent, "VisionClient/1.0")
c.Request = req
token := "vision-service-key-abc123"
sess := NewSessionFromToken(c, token, acl.ResourceVision.String(), "vision-api")
if assert.NotNil(t, sess) {
assert.Equal(t, http.StatusOK, sess.HttpStatus())
assert.Equal(t, token, sess.AuthToken())
assert.Equal(t, rnd.SessionID(token), sess.ID)
assert.Equal(t, acl.ResourceVision.String(), sess.Scope())
assert.Equal(t, authn.GrantToken, sess.GetGrantType())
assert.Equal(t, authn.MethodDefault, sess.GetMethod())
assert.Equal(t, authn.ProviderAccessToken, sess.GetProvider())
assert.Equal(t, header.ClientIP(c), sess.ClientIP)
assert.Equal(t, req.UserAgent(), sess.UserAgent)
assert.EqualValues(t, 60, sess.SessTimeout)
assert.True(t, rnd.IsRefID(sess.RefID))
}
})
}
func TestSession_SetData(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
m := NewSession(unix.Day, unix.Hour*6)

View File

@@ -15,6 +15,7 @@ const (
GrantUndefined GrantType = ""
GrantCLI GrantType = "cli"
GrantImplicit GrantType = "implicit"
GrantToken GrantType = "token"
GrantSession GrantType = "session"
GrantPassword GrantType = "password"
GrantClientCredentials GrantType = "client_credentials"
@@ -36,6 +37,8 @@ func Grant(s string) GrantType {
return GrantCLI
case "implicit":
return GrantImplicit
case "token":
return GrantToken
case "session":
return GrantSession
case "password", "passwd", "pass":
@@ -66,6 +69,8 @@ func (t GrantType) Pretty() string {
return "CLI"
case GrantImplicit:
return "Implicit"
case GrantToken:
return "Token"
case GrantSession:
return "Session"
case GrantPassword:

View File

@@ -11,6 +11,7 @@ func TestGrantType_String(t *testing.T) {
assert.Equal(t, "client_credentials", GrantClientCredentials.String())
assert.Equal(t, "session", GrantSession.String())
assert.Equal(t, "password", GrantPassword.String())
assert.Equal(t, "token", GrantToken.String())
assert.Equal(t, "refresh_token", GrantRefreshToken.String())
assert.Equal(t, "authorization_code", GrantAuthorizationCode.String())
assert.Equal(t, "authorization_code", GrantType("Authorization Code ").String())
@@ -22,6 +23,7 @@ func TestGrantType_Is(t *testing.T) {
assert.Equal(t, true, GrantUndefined.Is(GrantUndefined))
assert.Equal(t, true, GrantClientCredentials.Is(GrantClientCredentials))
assert.Equal(t, true, GrantSession.Is(GrantSession))
assert.Equal(t, true, GrantToken.Is(GrantToken))
assert.Equal(t, true, GrantPassword.Is(GrantPassword))
assert.Equal(t, false, GrantClientCredentials.Is(GrantPassword))
assert.Equal(t, false, GrantClientCredentials.Is(GrantRefreshToken))
@@ -36,6 +38,7 @@ func TestGrantType_IsNot(t *testing.T) {
assert.Equal(t, false, GrantUndefined.IsNot(GrantUndefined))
assert.Equal(t, false, GrantClientCredentials.IsNot(GrantClientCredentials))
assert.Equal(t, false, GrantPassword.IsNot(GrantPassword))
assert.Equal(t, false, GrantToken.IsNot(GrantToken))
assert.Equal(t, true, GrantClientCredentials.IsNot(GrantPassword))
assert.Equal(t, true, GrantClientCredentials.IsNot(GrantRefreshToken))
assert.Equal(t, true, GrantClientCredentials.IsNot(GrantAuthorizationCode))
@@ -57,6 +60,7 @@ func TestGrantType_Pretty(t *testing.T) {
assert.Equal(t, "CLI", GrantCLI.Pretty())
assert.Equal(t, "Client Credentials", GrantClientCredentials.Pretty())
assert.Equal(t, "Session", GrantSession.Pretty())
assert.Equal(t, "Token", GrantToken.Pretty())
assert.Equal(t, "Password", GrantPassword.Pretty())
assert.Equal(t, "Refresh Token", GrantRefreshToken.Pretty())
assert.Equal(t, "Authorization Code", GrantAuthorizationCode.Pretty())
@@ -98,6 +102,7 @@ func TestGrant(t *testing.T) {
assert.Equal(t, GrantCLI, Grant("cli"))
assert.Equal(t, GrantImplicit, Grant("implicit"))
assert.Equal(t, GrantSession, Grant("session"))
assert.Equal(t, GrantToken, Grant("token"))
assert.Equal(t, GrantPassword, Grant("pass"))
assert.Equal(t, GrantPassword, Grant("password"))
assert.Equal(t, GrantClientCredentials, Grant("client credentials"))