diff --git a/internal/ai/vision/config.go b/internal/ai/vision/config.go index d41171251..4160d0082 100644 --- a/internal/ai/vision/config.go +++ b/internal/ai/vision/config.go @@ -19,6 +19,7 @@ var ( CachePath = "" ModelsPath = "" DownloadUrl = "" + ServiceApi = false ServiceUri = "" ServiceKey = "" ServiceTimeout = 10 * time.Minute diff --git a/internal/api/api_auth.go b/internal/api/api_auth.go index 20034b009..05ab72732 100644 --- a/internal/api/api_auth.go +++ b/internal/api/api_auth.go @@ -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)) diff --git a/internal/api/api_auth_jwt_test.go b/internal/api/api_auth_jwt_test.go index b46c8b1cc..60daeadb4 100644 --- a/internal/api/api_auth_jwt_test.go +++ b/internal/api/api_auth_jwt_test.go @@ -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) diff --git a/internal/api/api_auth_test.go b/internal/api/api_auth_test.go index 0db940426..5a921e53d 100644 --- a/internal/api/api_auth_test.go +++ b/internal/api/api_auth_test.go @@ -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") diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 5d5232a64..b8fc4535b 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -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" }, diff --git a/internal/auth/acl/const.go b/internal/auth/acl/const.go index dcdc51e76..43e5bb757 100644 --- a/internal/auth/acl/const.go +++ b/internal/auth/acl/const.go @@ -1,5 +1,8 @@ package acl +// Any matches everything. +const Any = "*" + // RoleAliasNone is a more explicit, user-friendly alias for RoleNone. const RoleAliasNone = "none" diff --git a/internal/auth/acl/permission.go b/internal/auth/acl/permission.go index e3c43e2f6..d72d9831d 100644 --- a/internal/auth/acl/permission.go +++ b/internal/auth/acl/permission.go @@ -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()) diff --git a/internal/auth/acl/permission_test.go b/internal/auth/acl/permission_test.go index 2628aac2c..839127310 100644 --- a/internal/auth/acl/permission_test.go +++ b/internal/auth/acl/permission_test.go @@ -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")) diff --git a/internal/auth/acl/permissions.go b/internal/auth/acl/permissions.go index 803882555..c864664aa 100644 --- a/internal/auth/acl/permissions.go +++ b/internal/auth/acl/permissions.go @@ -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 +} diff --git a/internal/auth/acl/permissions_test.go b/internal/auth/acl/permissions_test.go new file mode 100644 index 000000000..05827cd83 --- /dev/null +++ b/internal/auth/acl/permissions_test.go @@ -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)) + }) +} diff --git a/internal/auth/acl/scope_descriptions.go b/internal/auth/acl/scope_descriptions.go index de628d56c..68bcbe800 100644 --- a/internal/auth/acl/scope_descriptions.go +++ b/internal/auth/acl/scope_descriptions.go @@ -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.", diff --git a/internal/config/config.go b/internal/config/config.go index 75af61037..7f2c68a79 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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() diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 89e3e3359..47ce96c8c 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -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} diff --git a/internal/entity/auth_session_jwt.go b/internal/entity/auth_session_jwt.go index 98cb15cd4..2c5ec1210 100644 --- a/internal/entity/auth_session_jwt.go +++ b/internal/entity/auth_session_jwt.go @@ -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)) diff --git a/internal/entity/auth_session_jwt_test.go b/internal/entity/auth_session_jwt_test.go index 75e75fd77..2c8c53667 100644 --- a/internal/entity/auth_session_jwt_test.go +++ b/internal/entity/auth_session_jwt_test.go @@ -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) diff --git a/internal/entity/auth_session_test.go b/internal/entity/auth_session_test.go index 3958212a4..7bd5a2d72 100644 --- a/internal/entity/auth_session_test.go +++ b/internal/entity/auth_session_test.go @@ -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) diff --git a/pkg/authn/grants.go b/pkg/authn/grants.go index 45e7c111c..9801bbee4 100644 --- a/pkg/authn/grants.go +++ b/pkg/authn/grants.go @@ -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: diff --git a/pkg/authn/grants_test.go b/pkg/authn/grants_test.go index eaae74863..8f65565d7 100644 --- a/pkg/authn/grants_test.go +++ b/pkg/authn/grants_test.go @@ -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"))