mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Merge branch 'develop' into feature/batch-edit
This commit is contained in:
@@ -250,6 +250,7 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
|
||||
- Treat `RoleAliasNone` ("none") and an empty string as `RoleNone`; no caller-specific overrides.
|
||||
- Default unknown client roles to `RoleClient`; `acl.ParseRole` already handles `0/false/nil` as none for users.
|
||||
- Build CLI role help from `Roles.CliUsageString()` (e.g., `acl.ClientRoles.CliUsageString()`); never hand-maintain role lists.
|
||||
- When checking JWT/client scopes, use the shared helpers (`acl.ScopePermits` / `acl.ScopeAttrPermits`) instead of hand-written parsing.
|
||||
|
||||
### Import/Index
|
||||
|
||||
|
@@ -80,7 +80,7 @@ Database & Migrations
|
||||
|
||||
AuthN/Z & Sessions
|
||||
- Session model and cache: `internal/entity/auth_session*` and `internal/auth/session/*` (cleanup worker).
|
||||
- ACL: `internal/auth/acl/*` — roles, grants, scopes; use constants; avoid logging secrets, compare tokens constant‑time.
|
||||
- ACL: `internal/auth/acl/*` — roles, grants, scopes; use constants; avoid logging secrets, compare tokens constant‑time; for scope checks use `acl.ScopePermits` / `ScopeAttrPermits` instead of rolling your own parsing.
|
||||
- OIDC: `internal/auth/oidc/*`.
|
||||
|
||||
Media Processing
|
||||
|
18
Makefile
18
Makefile
@@ -72,15 +72,15 @@ watch: watch-js
|
||||
build-all: build-go build-js
|
||||
pull: docker-pull
|
||||
test: test-js test-go
|
||||
test-go: reset-sqlite run-test-go
|
||||
test-pkg: reset-sqlite run-test-pkg
|
||||
test-ai: reset-sqlite run-test-ai
|
||||
test-api: reset-sqlite run-test-api
|
||||
test-video: reset-sqlite run-test-video
|
||||
test-entity: reset-sqlite run-test-entity
|
||||
test-commands: reset-sqlite run-test-commands
|
||||
test-photoprism: reset-sqlite run-test-photoprism
|
||||
test-short: reset-sqlite run-test-short
|
||||
test-go: run-test-go
|
||||
test-pkg: run-test-pkg
|
||||
test-ai: run-test-ai
|
||||
test-api: run-test-api
|
||||
test-video: run-test-video
|
||||
test-entity: run-test-entity
|
||||
test-commands: run-test-commands
|
||||
test-photoprism: run-test-photoprism
|
||||
test-short: run-test-short
|
||||
test-mariadb: reset-acceptance run-test-mariadb
|
||||
acceptance-run-chromium: storage/acceptance acceptance-auth-sqlite-restart wait acceptance-auth acceptance-auth-sqlite-stop acceptance-sqlite-restart wait-2 acceptance acceptance-sqlite-stop
|
||||
acceptance-run-chromium-short: storage/acceptance acceptance-auth-sqlite-restart wait acceptance-auth-short acceptance-auth-sqlite-stop acceptance-sqlite-restart wait-2 acceptance-short acceptance-sqlite-stop
|
||||
|
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -16,78 +18,132 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster
|
||||
// node receives a request without an existing session. It verifies the token
|
||||
// against the node's cached JWKS, ensures the issuer/audience/scope match the
|
||||
// expected portal values, and, if valid, returns a client session mirroring the
|
||||
// JWT claims. It returns nil on any validation failure so the caller can fall
|
||||
// back to existing auth flows. Currently cluster and vision resources are
|
||||
// eligible for JWT-based authorization; vision access requires the `vision`
|
||||
// scope whereas cluster access requires the `cluster` scope.
|
||||
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster node
|
||||
// receives a request without an existing session. It verifies the token against
|
||||
// the node's cached JWKS, ensures the issuer/audience/scope match the expected
|
||||
// portal values, and, if valid, returns a client session mirroring the JWT
|
||||
// claims. It returns nil on any validation failure so the caller can fall back
|
||||
// to existing auth flows. By default, only cluster and vision resources are
|
||||
// eligible, but nodes may opt in to additional scopes via PHOTOPRISM_JWT_SCOPE.
|
||||
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
|
||||
if c == nil || authToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = perms
|
||||
|
||||
if resource != acl.ResourceCluster && resource != acl.ResourceVision {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Basic sanity check for JWT structure.
|
||||
if strings.Count(authToken, ".") != 2 {
|
||||
// Check if token may be a JWT.
|
||||
if !shouldAttemptJWT(c, authToken) {
|
||||
return nil
|
||||
}
|
||||
|
||||
conf := get.Config()
|
||||
|
||||
if conf == nil || conf.IsPortal() {
|
||||
// Determine whether JWT authentication is possible
|
||||
// based on the local config and client IP address.
|
||||
if !shouldAllowJWT(conf, clientIP) {
|
||||
return nil
|
||||
}
|
||||
|
||||
requiredScope := resource.String()
|
||||
expected := expectedClaimsFor(conf, requiredScope)
|
||||
|
||||
// verifyTokenFromPortal handles cryptographic validation (signature, issuer,
|
||||
// audience, temporal claims) and enforces that the token includes any scopes
|
||||
// listed in expected.Scope. Local authorization still happens below so nodes
|
||||
// can apply their own allow-list semantics.
|
||||
claims := verifyTokenFromPortal(c.Request.Context(), authToken, expected, jwtIssuerCandidates(conf))
|
||||
|
||||
if claims == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if config allows resource access to be authorized with JWT.
|
||||
allowedScopes := conf.JWTAllowedScopes()
|
||||
if !acl.ScopeAttrPermits(allowedScopes, resource, perms) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if token allows access to specified resource.
|
||||
tokenScopes := acl.ScopeAttr(claims.Scope)
|
||||
if !acl.ScopeAttrPermits(tokenScopes, resource, perms) {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims.Scope = tokenScopes.String()
|
||||
|
||||
return sessionFromJWTClaims(claims, clientIP)
|
||||
}
|
||||
|
||||
// shouldAttemptJWT reports whether JWT verification should run for the supplied
|
||||
// request context and token.
|
||||
func shouldAttemptJWT(c *gin.Context, token string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if token == "" || strings.Count(token, ".") != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldAllowJWT reports whether the current node configuration permits JWT
|
||||
// authentication for the request originating from clientIP.
|
||||
func shouldAllowJWT(conf *config.Config, clientIP string) bool {
|
||||
if conf == nil || conf.IsPortal() {
|
||||
return false
|
||||
}
|
||||
|
||||
if conf.JWKSUrl() == "" {
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
requiredScopes := []string{"cluster"}
|
||||
if resource == acl.ResourceVision {
|
||||
requiredScopes = []string{"vision"}
|
||||
cidr := strings.TrimSpace(conf.ClusterCIDR())
|
||||
if cidr == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(clientIP)
|
||||
_, block, err := net.ParseCIDR(cidr)
|
||||
if err != nil || ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return block.Contains(ip)
|
||||
}
|
||||
|
||||
// expectedClaimsFor builds the ExpectedClaims used to validate JWTs for the
|
||||
// current node and required scope.
|
||||
func expectedClaimsFor(conf *config.Config, requiredScope string) clusterjwt.ExpectedClaims {
|
||||
expected := clusterjwt.ExpectedClaims{
|
||||
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
|
||||
Scope: requiredScopes,
|
||||
JWKSURL: conf.JWKSUrl(),
|
||||
}
|
||||
|
||||
issuers := jwtIssuerCandidates(conf)
|
||||
if requiredScope != "" {
|
||||
expected.Scope = []string{requiredScope}
|
||||
}
|
||||
|
||||
return expected
|
||||
}
|
||||
|
||||
// verifyTokenFromPortal checks the token against each candidate issuer and
|
||||
// returns the verified claims on success.
|
||||
func verifyTokenFromPortal(ctx context.Context, token string, expected clusterjwt.ExpectedClaims, issuers []string) *clusterjwt.Claims {
|
||||
if len(issuers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
claims *clusterjwt.Claims
|
||||
err error
|
||||
)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
for _, issuer := range issuers {
|
||||
expected.Issuer = issuer
|
||||
claims, err = get.VerifyJWT(ctx, authToken, expected)
|
||||
claims, err := get.VerifyJWT(ctx, token, expected)
|
||||
if err == nil {
|
||||
break
|
||||
return claims
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
} else if claims == nil {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sessionFromJWTClaims constructs a Session populated with fields derived from
|
||||
// the verified JWT claims.
|
||||
func sessionFromJWTClaims(claims *clusterjwt.Claims, clientIP string) *entity.Session {
|
||||
sess := &entity.Session{
|
||||
Status: http.StatusOK,
|
||||
ClientUID: claims.Subject,
|
||||
|
@@ -1,17 +1,22 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
func TestAuthAnyJWT(t *testing.T) {
|
||||
@@ -35,7 +40,149 @@ func TestAuthAnyJWT(t *testing.T) {
|
||||
assert.Contains(t, session.AuthScope, "cluster")
|
||||
assert.Equal(t, spec.Issuer, session.AuthIssuer)
|
||||
})
|
||||
t.Run("ClusterCIDRAllowed", func(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "cluster-jwt-cidr-allow")
|
||||
spec := fx.defaultClaimsSpec()
|
||||
token := fx.issue(t, spec)
|
||||
|
||||
origCIDR := fx.nodeConf.Options().ClusterCIDR
|
||||
fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
|
||||
get.SetConfig(fx.nodeConf)
|
||||
t.Cleanup(func() {
|
||||
fx.nodeConf.Options().ClusterCIDR = origCIDR
|
||||
get.SetConfig(fx.nodeConf)
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "192.0.2.10:2222"
|
||||
c.Request = req
|
||||
|
||||
session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
|
||||
require.NotNil(t, session)
|
||||
assert.Equal(t, spec.Subject, session.ClientUID)
|
||||
})
|
||||
t.Run("ClusterCIDRBlocked", func(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "cluster-jwt-cidr-block")
|
||||
spec := fx.defaultClaimsSpec()
|
||||
token := fx.issue(t, spec)
|
||||
|
||||
origCIDR := fx.nodeConf.Options().ClusterCIDR
|
||||
fx.nodeConf.Options().ClusterCIDR = "192.0.2.0/24"
|
||||
get.SetConfig(fx.nodeConf)
|
||||
t.Cleanup(func() {
|
||||
fx.nodeConf.Options().ClusterCIDR = origCIDR
|
||||
get.SetConfig(fx.nodeConf)
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "203.0.113.10:2222"
|
||||
c.Request = req
|
||||
|
||||
assert.Nil(t, authAnyJWT(c, "203.0.113.10", token, acl.ResourceCluster, nil))
|
||||
})
|
||||
t.Run("JWTScopeDefaultRejectsOtherResources", func(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "cluster-jwt-scope-default-reject")
|
||||
spec := fx.defaultClaimsSpec()
|
||||
spec.Scope = []string{"photos"}
|
||||
token := fx.issue(t, spec)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "192.0.2.60:1001"
|
||||
c.Request = req
|
||||
|
||||
assert.Nil(t, authAnyJWT(c, "192.0.2.60", token, acl.ResourcePhotos, nil))
|
||||
})
|
||||
t.Run("JWTScopeAllowed", func(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "cluster-jwt-scope-allow")
|
||||
token := fx.issue(t, fx.defaultClaimsSpec())
|
||||
|
||||
orig := fx.nodeConf.Options().JWTScope
|
||||
fx.nodeConf.Options().JWTScope = "cluster vision"
|
||||
get.SetConfig(fx.nodeConf)
|
||||
t.Cleanup(func() {
|
||||
fx.nodeConf.Options().JWTScope = orig
|
||||
get.SetConfig(fx.nodeConf)
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "192.0.2.30:1001"
|
||||
c.Request = req
|
||||
|
||||
sess := authAnyJWT(c, "192.0.2.30", token, acl.ResourceCluster, nil)
|
||||
require.NotNil(t, sess)
|
||||
})
|
||||
t.Run("JWTScopeAllowsSuperset", func(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "cluster-jwt-scope-reject")
|
||||
token := fx.issue(t, fx.defaultClaimsSpec())
|
||||
|
||||
orig := fx.nodeConf.Options().JWTScope
|
||||
fx.nodeConf.Options().JWTScope = "cluster"
|
||||
get.SetConfig(fx.nodeConf)
|
||||
t.Cleanup(func() {
|
||||
fx.nodeConf.Options().JWTScope = orig
|
||||
get.SetConfig(fx.nodeConf)
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "192.0.2.40:1001"
|
||||
c.Request = req
|
||||
|
||||
sess := authAnyJWT(c, "192.0.2.40", token, acl.ResourceCluster, nil)
|
||||
require.NotNil(t, sess)
|
||||
})
|
||||
t.Run("JWTScopeCustomResource", func(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "cluster-jwt-scope-custom")
|
||||
spec := fx.defaultClaimsSpec()
|
||||
spec.Scope = []string{"photos"}
|
||||
token := fx.issue(t, spec)
|
||||
|
||||
origScope := fx.nodeConf.Options().JWTScope
|
||||
fx.nodeConf.Options().JWTScope = "photos"
|
||||
get.SetConfig(fx.nodeConf)
|
||||
t.Cleanup(func() {
|
||||
fx.nodeConf.Options().JWTScope = origScope
|
||||
get.SetConfig(fx.nodeConf)
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.RemoteAddr = "192.0.2.50:2001"
|
||||
c.Request = req
|
||||
|
||||
_, verifyErr := get.VerifyJWT(c.Request.Context(), token, clusterjwt.ExpectedClaims{
|
||||
Issuer: fmt.Sprintf("portal:%s", fx.clusterUUID),
|
||||
Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
|
||||
Scope: []string{"photos"},
|
||||
JWKSURL: fx.nodeConf.JWKSUrl(),
|
||||
})
|
||||
require.NoError(t, verifyErr)
|
||||
|
||||
sess := authAnyJWT(c, "192.0.2.50", token, acl.ResourcePhotos, nil)
|
||||
require.NotNil(t, sess)
|
||||
})
|
||||
t.Run("VisionScope", func(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "cluster-jwt-vision")
|
||||
spec := fx.defaultClaimsSpec()
|
||||
@@ -146,3 +293,83 @@ func TestJwtIssuerCandidates(t *testing.T) {
|
||||
assert.Equal(t, []string{"http://localhost:2342"}, jwtIssuerCandidates(conf))
|
||||
})
|
||||
}
|
||||
|
||||
func TestShouldAttemptJWT(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
|
||||
c.Request = req
|
||||
|
||||
assert.True(t, shouldAttemptJWT(c, "a.b.c"))
|
||||
assert.False(t, shouldAttemptJWT(nil, "a.b.c"))
|
||||
assert.False(t, shouldAttemptJWT(c, "invalidtoken"))
|
||||
assert.False(t, shouldAttemptJWT(c, ""))
|
||||
}
|
||||
|
||||
func TestNodeAllowsJWT(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "node-allows")
|
||||
conf := fx.nodeConf
|
||||
|
||||
assert.True(t, shouldAllowJWT(conf, "192.0.2.9"))
|
||||
|
||||
origCIDR := conf.Options().ClusterCIDR
|
||||
conf.Options().ClusterCIDR = "192.0.2.0/24"
|
||||
assert.True(t, shouldAllowJWT(conf, "192.0.2.25"))
|
||||
assert.False(t, shouldAllowJWT(conf, "203.0.113.1"))
|
||||
conf.Options().ClusterCIDR = origCIDR
|
||||
|
||||
origJWKS := conf.JWKSUrl()
|
||||
conf.SetJWKSUrl("")
|
||||
assert.False(t, shouldAllowJWT(conf, "192.0.2.25"))
|
||||
conf.SetJWKSUrl(origJWKS)
|
||||
|
||||
assert.False(t, shouldAllowJWT(nil, "192.0.2.25"))
|
||||
}
|
||||
|
||||
func TestExpectedClaimsFor(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "expected-claims")
|
||||
|
||||
claims := expectedClaimsFor(fx.nodeConf, "cluster")
|
||||
assert.Equal(t, fmt.Sprintf("node:%s", fx.nodeUUID), claims.Audience)
|
||||
assert.Equal(t, []string{"cluster"}, claims.Scope)
|
||||
assert.Equal(t, fx.nodeConf.JWKSUrl(), claims.JWKSURL)
|
||||
|
||||
noScope := expectedClaimsFor(fx.nodeConf, "")
|
||||
assert.Nil(t, noScope.Scope)
|
||||
}
|
||||
|
||||
func TestVerifyTokenFromPortal(t *testing.T) {
|
||||
fx := newPortalJWTFixture(t, "verify-token")
|
||||
spec := fx.defaultClaimsSpec()
|
||||
token := fx.issue(t, spec)
|
||||
|
||||
expected := expectedClaimsFor(fx.nodeConf, clean.Scope("cluster"))
|
||||
claims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong", spec.Issuer})
|
||||
require.NotNil(t, claims)
|
||||
assert.Equal(t, spec.Issuer, claims.Issuer)
|
||||
assert.Equal(t, spec.Subject, claims.Subject)
|
||||
|
||||
nilClaims := verifyTokenFromPortal(context.Background(), token, expected, []string{"wrong"})
|
||||
assert.Nil(t, nilClaims)
|
||||
}
|
||||
|
||||
func TestSessionFromJWTClaims(t *testing.T) {
|
||||
claims := &clusterjwt.Claims{
|
||||
Scope: "cluster vision",
|
||||
RegisteredClaims: gojwt.RegisteredClaims{
|
||||
Issuer: "portal:test",
|
||||
Subject: "portal:client",
|
||||
ID: "token-id",
|
||||
},
|
||||
}
|
||||
|
||||
sess := sessionFromJWTClaims(claims, "192.0.2.100")
|
||||
require.NotNil(t, sess)
|
||||
assert.Equal(t, http.StatusOK, sess.HttpStatus())
|
||||
assert.Equal(t, "portal:client", sess.ClientUID)
|
||||
assert.Equal(t, clean.Scope("cluster vision"), sess.AuthScope)
|
||||
assert.Equal(t, "portal:test", sess.AuthIssuer)
|
||||
assert.Equal(t, "token-id", sess.AuthID)
|
||||
assert.Equal(t, "192.0.2.100", sess.ClientIP)
|
||||
}
|
||||
|
@@ -229,6 +229,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
|
||||
resp := cluster.RegisterResponse{
|
||||
UUID: conf.ClusterUUID(),
|
||||
ClusterCIDR: conf.ClusterCIDR(),
|
||||
Node: reg.BuildClusterNode(*n, opts),
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
|
||||
Secrets: respSecret,
|
||||
@@ -299,6 +300,8 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
resp := cluster.RegisterResponse{
|
||||
UUID: conf.ClusterUUID(),
|
||||
ClusterCIDR: conf.ClusterCIDR(),
|
||||
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
|
||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt},
|
||||
|
@@ -46,10 +46,11 @@ func ClusterSummary(router *gin.RouterGroup) {
|
||||
nodes, _ := regy.List()
|
||||
|
||||
c.JSON(http.StatusOK, cluster.SummaryResponse{
|
||||
UUID: conf.ClusterUUID(),
|
||||
Nodes: len(nodes),
|
||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
UUID: conf.ClusterUUID(),
|
||||
ClusterCIDR: conf.ClusterCIDR(),
|
||||
Nodes: len(nodes),
|
||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@@ -419,6 +419,9 @@
|
||||
"alreadyRegistered": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterCidr": {
|
||||
"type": "string"
|
||||
},
|
||||
"database": {
|
||||
"$ref": "#/definitions/cluster.RegisterDatabase"
|
||||
},
|
||||
@@ -459,6 +462,9 @@
|
||||
},
|
||||
"cluster.SummaryResponse": {
|
||||
"properties": {
|
||||
"clusterCidr": {
|
||||
"type": "string"
|
||||
},
|
||||
"database": {
|
||||
"$ref": "#/definitions/cluster.DatabaseInfo"
|
||||
},
|
||||
|
@@ -1,5 +1,11 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
)
|
||||
|
||||
// Permission scopes to Grant multiple Permissions for a Resource.
|
||||
const (
|
||||
ScopeRead Permission = "read"
|
||||
@@ -35,3 +41,63 @@ var (
|
||||
ActionManageOwn: true,
|
||||
}
|
||||
)
|
||||
|
||||
// ScopeAttr parses an auth scope string and returns a normalized Attr
|
||||
// with duplicate and invalid entries removed.
|
||||
func ScopeAttr(s string) list.Attr {
|
||||
if s == "" {
|
||||
return list.Attr{}
|
||||
}
|
||||
|
||||
return list.ParseAttr(strings.ToLower(s))
|
||||
}
|
||||
|
||||
// ScopePermits sanitizes the raw scope string and then calls ScopeAttrPermits for
|
||||
// the actual authorization check.
|
||||
func ScopePermits(scope string, resource Resource, perms Permissions) bool {
|
||||
if scope == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse scope to check for resources and permissions.
|
||||
return ScopeAttrPermits(ScopeAttr(scope), resource, perms)
|
||||
}
|
||||
|
||||
// ScopeAttrPermits evaluates an already-parsed scope attribute list against a
|
||||
// resource and permission set, enforcing wildcard/read/write semantics.
|
||||
func ScopeAttrPermits(attr list.Attr, resource Resource, perms Permissions) bool {
|
||||
if len(attr) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
scope := attr.String()
|
||||
|
||||
// Skip detailed check and allow all if scope is "*".
|
||||
if scope == list.Any {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip resource check if scope includes all read operations.
|
||||
if scope == ScopeRead.String() {
|
||||
return !GrantScopeRead.DenyAny(perms)
|
||||
}
|
||||
|
||||
// Check if resource is within scope.
|
||||
if granted := attr.Contains(resource.String()); !granted {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if permission is within scope.
|
||||
if len(perms) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if scope is limited to read or write operations.
|
||||
if a := attr.Find(ScopeRead.String()); a.Value == list.True && GrantScopeRead.DenyAny(perms) {
|
||||
return false
|
||||
} else if a = attr.Find(ScopeWrite.String()); a.Value == list.True && GrantScopeWrite.DenyAny(perms) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
@@ -35,3 +35,136 @@ func TestGrantScopeWrite(t *testing.T) {
|
||||
assert.False(t, GrantScopeWrite.DenyAny(Permissions{AccessAll}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestScopePermits(t *testing.T) {
|
||||
t.Run("AnyScope", func(t *testing.T) {
|
||||
assert.True(t, ScopePermits("*", "", nil))
|
||||
})
|
||||
t.Run("ReadScope", func(t *testing.T) {
|
||||
assert.True(t, ScopePermits("read", "metrics", nil))
|
||||
assert.True(t, ScopePermits("read", "sessions", nil))
|
||||
assert.True(t, ScopePermits("read", "metrics", Permissions{ActionView, AccessAll}))
|
||||
assert.False(t, ScopePermits("read", "metrics", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read", "metrics", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read", "settings", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read", "settings", Permissions{ActionCreate}))
|
||||
assert.False(t, ScopePermits("read", "sessions", Permissions{ActionDelete}))
|
||||
})
|
||||
t.Run("ReadAny", func(t *testing.T) {
|
||||
assert.True(t, ScopePermits("read *", "metrics", nil))
|
||||
assert.True(t, ScopePermits("read *", "sessions", nil))
|
||||
assert.True(t, ScopePermits("read *", "metrics", Permissions{ActionView, AccessAll}))
|
||||
assert.False(t, ScopePermits("read *", "metrics", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read *", "metrics", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read *", "settings", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read *", "settings", Permissions{ActionCreate}))
|
||||
assert.False(t, ScopePermits("read *", "sessions", Permissions{ActionDelete}))
|
||||
})
|
||||
t.Run("ReadSettings", func(t *testing.T) {
|
||||
assert.True(t, ScopePermits("read settings", "settings", Permissions{ActionView}))
|
||||
assert.False(t, ScopePermits("read settings", "metrics", nil))
|
||||
assert.False(t, ScopePermits("read settings", "sessions", nil))
|
||||
assert.False(t, ScopePermits("read settings", "metrics", Permissions{ActionView, AccessAll}))
|
||||
assert.False(t, ScopePermits("read settings", "metrics", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read settings", "metrics", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read settings", "settings", Permissions{ActionUpdate}))
|
||||
assert.False(t, ScopePermits("read settings", "sessions", Permissions{ActionDelete}))
|
||||
assert.False(t, ScopePermits("read settings", "sessions", Permissions{ActionDelete}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestScopeAttr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{name: "Empty", input: "", expected: nil},
|
||||
{name: "Lowercase", input: "read metrics", expected: []string{"metrics", "read"}},
|
||||
{name: "Uppercase", input: "READ SETTINGS", expected: []string{"read", "settings"}},
|
||||
{name: "WithNoise", input: " Read\tSessions\nmetrics", expected: []string{"metrics", "read", "sessions"}},
|
||||
{name: "Deduplicates", input: "metrics metrics", expected: []string{"metrics"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attr := ScopeAttr(tc.input)
|
||||
if len(tc.expected) == 0 {
|
||||
assert.Len(t, attr, 0)
|
||||
return
|
||||
}
|
||||
assert.ElementsMatch(t, tc.expected, attr.Strings())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopePermitsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope string
|
||||
resource Resource
|
||||
perms Permissions
|
||||
want bool
|
||||
}{
|
||||
{name: "EmptyScope", scope: "", resource: "metrics", perms: nil, want: false},
|
||||
{name: "OnlyInvalidChars", scope: "()", resource: "metrics", perms: nil, want: false},
|
||||
{name: "WildcardMixedOrder", scope: "* read metrics", resource: "metrics", perms: Permissions{ActionUpdate}, want: false},
|
||||
{name: "WildcardOverridesReadRestrictions", scope: "read metrics *", resource: "metrics", perms: Permissions{ActionDelete}, want: false},
|
||||
{name: "WildcardWithFalseValueIgnored", scope: "*:false read", resource: "metrics", perms: Permissions{ActionUpdate}, want: false},
|
||||
{name: "ExplicitFalseResource", scope: "metrics:false", resource: "metrics", perms: nil, want: false},
|
||||
{name: "ExplicitTrueResource", scope: "metrics:true", resource: "metrics", perms: nil, want: true},
|
||||
{name: "CaseInsensitiveScopeAndResource", scope: "READ SETTINGS", resource: Resource("Settings"), perms: Permissions{ActionView}, want: true},
|
||||
{name: "WhitespaceAndTabs", scope: "\tread\tsettings\n", resource: "settings", perms: Permissions{ActionView}, want: true},
|
||||
{name: "DefaultResourceRead", scope: "read default", resource: "", perms: Permissions{ActionView}, want: true},
|
||||
{name: "DefaultResourceUpdateDenied", scope: "read default", resource: "", perms: Permissions{ActionUpdate}, want: false},
|
||||
{name: "WriteAllowsMutation", scope: "write settings", resource: "settings", perms: Permissions{ActionUpdate}, want: true},
|
||||
{name: "WriteBlocksReadOnly", scope: "write settings", resource: "settings", perms: Permissions{ActionView}, want: false},
|
||||
{name: "ReadGrantAllowsAccessAll", scope: "read", resource: "metrics", perms: Permissions{AccessAll}, want: true},
|
||||
{name: "ReadGrantDeniesManage", scope: "read metrics", resource: "metrics", perms: Permissions{ActionManage}, want: false},
|
||||
{name: "WriteGrantAllowsManage", scope: "write metrics", resource: "metrics", perms: Permissions{ActionManage}, want: true},
|
||||
{name: "ResourceWildcard", scope: "metrics:*", resource: "metrics", perms: Permissions{ActionDelete}, want: true},
|
||||
{name: "GlobalWildcardWithoutRead", scope: "* metrics", resource: "metrics", perms: Permissions{ActionDelete}, want: true},
|
||||
{name: "ResourceWildcardWithRead", scope: "read metrics:*", resource: "metrics", perms: Permissions{ActionView}, want: true},
|
||||
{name: "ResourceWildcardWriteDenied", scope: "read metrics:*", resource: "metrics", perms: Permissions{ActionUpdate}, want: false},
|
||||
{name: "DuplicateAndNoise", scope: " read metrics metrics ", resource: "metrics", perms: nil, want: true},
|
||||
{name: "FalseOverridesTrue", scope: "metrics metrics:false", resource: "metrics", perms: nil, want: false},
|
||||
{name: "CaseInsensitiveResourceLookup", scope: "read metrics", resource: Resource("METRICS"), perms: Permissions{ActionView}, want: true},
|
||||
{name: "MixedReadWriteConflict", scope: "read write settings", resource: "settings", perms: Permissions{ActionUpdate}, want: false},
|
||||
{name: "PermissionsEmptySlice", scope: "read metrics", resource: "metrics", perms: Permissions{}, want: true},
|
||||
{name: "SimpleNonReadScopeAllows", scope: "cluster vision", resource: "cluster", perms: nil, want: true},
|
||||
{name: "SimpleNonReadScopeRejectsMissing", scope: "cluster vision", resource: "portal", perms: nil, want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ScopePermits(tc.scope, tc.resource, tc.perms)
|
||||
assert.Equalf(t, tc.want, got, "scope %q resource %q perms %v", tc.scope, tc.resource, tc.perms)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeAttrPermits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope string
|
||||
resource Resource
|
||||
perms Permissions
|
||||
want bool
|
||||
}{
|
||||
{name: "EmptyAttr", scope: "", resource: "metrics", perms: nil, want: false},
|
||||
{name: "Wildcard", scope: "*", resource: "metrics", perms: Permissions{ActionUpdate}, want: true},
|
||||
{name: "ReadAllowsView", scope: "read", resource: "settings", perms: Permissions{ActionView}, want: true},
|
||||
{name: "ReadBlocksUpdate", scope: "read", resource: "settings", perms: Permissions{ActionUpdate}, want: false},
|
||||
{name: "ResourceMismatch", scope: "read metrics", resource: "settings", perms: nil, want: false},
|
||||
{name: "WriteAllowsManage", scope: "write metrics", resource: "metrics", perms: Permissions{ActionManage}, want: true},
|
||||
{name: "WriteBlocksView", scope: "write metrics", resource: "metrics", perms: Permissions{ActionView}, want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attr := ScopeAttr(tc.scope)
|
||||
got := ScopeAttrPermits(attr, tc.resource, tc.perms)
|
||||
assert.Equalf(t, tc.want, got, "scope %q resource %q perms %v", tc.scope, tc.resource, tc.perms)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -71,7 +71,7 @@ func clientsAddAction(ctx *cli.Context) error {
|
||||
|
||||
// Set a default client name if no specific name has been provided.
|
||||
if frm.AuthScope == "" {
|
||||
frm.AuthScope = list.All
|
||||
frm.AuthScope = list.Any
|
||||
}
|
||||
|
||||
client, addErr := entity.AddClient(frm)
|
||||
|
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
@@ -330,6 +331,16 @@ func parseLabelSlice(labels []string) map[string]string {
|
||||
|
||||
// Persistence helpers for --write-config
|
||||
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
|
||||
updates := map[string]any{}
|
||||
|
||||
if rnd.IsUUID(resp.UUID) {
|
||||
updates["ClusterUUID"] = resp.UUID
|
||||
}
|
||||
|
||||
if cidr := strings.TrimSpace(resp.ClusterCIDR); cidr != "" {
|
||||
updates["ClusterCIDR"] = cidr
|
||||
}
|
||||
|
||||
// Node client secret file
|
||||
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
||||
// Prefer PHOTOPRISM_NODE_CLIENT_SECRET_FILE; otherwise config cluster path
|
||||
@@ -348,16 +359,18 @@ func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse
|
||||
|
||||
// DB settings (MySQL/MariaDB only)
|
||||
if resp.Database.Name != "" && resp.Database.User != "" {
|
||||
if err := mergeOptionsYaml(conf, map[string]any{
|
||||
"DatabaseDriver": config.MySQL,
|
||||
"DatabaseName": resp.Database.Name,
|
||||
"DatabaseServer": fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port),
|
||||
"DatabaseUser": resp.Database.User,
|
||||
"DatabasePassword": resp.Database.Password,
|
||||
}); err != nil {
|
||||
updates["DatabaseDriver"] = config.MySQL
|
||||
updates["DatabaseName"] = resp.Database.Name
|
||||
updates["DatabaseServer"] = fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port)
|
||||
updates["DatabaseUser"] = resp.Database.User
|
||||
updates["DatabasePassword"] = resp.Database.Password
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := mergeOptionsYaml(conf, updates); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Infof("updated options.yml with database settings for node %s", clean.LogQuote(resp.Node.Name))
|
||||
log.Infof("updated options.yml with cluster registration settings for node %s", clean.LogQuote(resp.Node.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -36,10 +36,11 @@ func clusterSummaryAction(ctx *cli.Context) error {
|
||||
nodes, _ := r.List()
|
||||
|
||||
resp := cluster.SummaryResponse{
|
||||
UUID: conf.ClusterUUID(),
|
||||
Nodes: len(nodes),
|
||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
UUID: conf.ClusterUUID(),
|
||||
ClusterCIDR: conf.ClusterCIDR(),
|
||||
Nodes: len(nodes),
|
||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||
Time: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if ctx.Bool("json") {
|
||||
@@ -48,8 +49,8 @@ func clusterSummaryAction(ctx *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
cols := []string{"Portal UUID", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
|
||||
rows := [][]string{{resp.UUID, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
|
||||
cols := []string{"Portal UUID", "Cluster CIDR", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
|
||||
rows := [][]string{{resp.UUID, resp.ClusterCIDR, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
|
||||
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
fmt.Printf("\n%s\n", out)
|
||||
return err
|
||||
|
@@ -95,9 +95,10 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return NodeClientID and a fresh secret
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
|
||||
UUID: rnd.UUID(),
|
||||
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
|
||||
UUID: rnd.UUID(),
|
||||
ClusterCIDR: "203.0.113.0/24",
|
||||
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
|
||||
})
|
||||
case "/api/v1/oauth/token":
|
||||
// Expect Basic for the returned creds
|
||||
|
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
)
|
||||
@@ -278,6 +279,18 @@ func (c *Config) JWTLeeway() int {
|
||||
return c.options.JWTLeeway
|
||||
}
|
||||
|
||||
// JWTAllowedScopes returns an optional allow-list of accepted JWT scopes.
|
||||
func (c *Config) JWTAllowedScopes() list.Attr {
|
||||
if s := strings.TrimSpace(c.options.JWTScope); s != "" {
|
||||
parsed := list.ParseAttr(strings.ToLower(s))
|
||||
if len(parsed) > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return list.ParseAttr("cluster vision metrics")
|
||||
}
|
||||
|
||||
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
|
||||
func (c *Config) AdvertiseUrl() string {
|
||||
if c.options.AdvertiseUrl != "" {
|
||||
|
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
@@ -145,6 +146,13 @@ func TestConfig_Cluster(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
t.Run("JWTAllowedScopes", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
c.options.JWTScope = "cluster vision"
|
||||
assert.Equal(t, list.ParseAttr("cluster vision"), c.JWTAllowedScopes())
|
||||
c.options.JWTScope = ""
|
||||
assert.Equal(t, list.ParseAttr("cluster vision metrics"), c.JWTAllowedScopes())
|
||||
})
|
||||
t.Run("Paths", func(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
|
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// ApplyScope updates the current settings based on the authorization scope passed.
|
||||
func (s *Settings) ApplyScope(scope string) *Settings {
|
||||
if scope == "" || scope == list.All {
|
||||
if scope == "" || scope == list.Any {
|
||||
return s
|
||||
}
|
||||
|
||||
|
@@ -731,6 +731,11 @@ var Flags = CliFlags{
|
||||
Value: 300,
|
||||
EnvVars: EnvVars("JWKS_CACHE_TTL"),
|
||||
}}, {
|
||||
Flag: &cli.StringFlag{
|
||||
Name: "jwt-scope",
|
||||
Usage: "allowed JWT `SCOPES` (space separated). Leave empty to accept defaults",
|
||||
EnvVars: EnvVars("JWT_SCOPE"),
|
||||
}}, {
|
||||
Flag: &cli.IntFlag{
|
||||
Name: "jwt-leeway",
|
||||
Usage: "JWT clock skew allowance in `SECONDS` (default 60, max 300)",
|
||||
|
@@ -154,6 +154,7 @@ type Options struct {
|
||||
NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"`
|
||||
JWKSUrl string `yaml:"JWKSUrl" json:"-" flag:"jwks-url"`
|
||||
JWKSCacheTTL int `yaml:"JWKSCacheTTL" json:"-" flag:"jwks-cache-ttl"`
|
||||
JWTScope string `yaml:"JWTScope" json:"-" flag:"jwt-scope"`
|
||||
JWTLeeway int `yaml:"JWTLeeway" json:"-" flag:"jwt-leeway"`
|
||||
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
|
||||
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
|
||||
|
@@ -190,6 +190,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
|
||||
{"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))},
|
||||
{"jwks-url", c.JWKSUrl()},
|
||||
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
|
||||
{"jwt-scope", c.JWTAllowedScopes().String()},
|
||||
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
|
||||
{"advertise-url", c.AdvertiseUrl()},
|
||||
|
||||
|
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||
"github.com/photoprism/photoprism/pkg/time/unix"
|
||||
@@ -492,40 +491,7 @@ func (m *Session) Scope() string {
|
||||
|
||||
// ValidateScope checks if the scope does not exclude access to specified resource.
|
||||
func (m *Session) ValidateScope(resource acl.Resource, perms acl.Permissions) bool {
|
||||
// Get scope string.
|
||||
scope := m.Scope()
|
||||
|
||||
// Skip detailed check and allow all if scope is "*".
|
||||
if scope == list.All {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip resource check if scope includes all read operations.
|
||||
if scope == acl.ScopeRead.String() {
|
||||
return !acl.GrantScopeRead.DenyAny(perms)
|
||||
}
|
||||
|
||||
// Parse scope to check for resources and permissions.
|
||||
attr := list.ParseAttr(scope)
|
||||
|
||||
// Check if resource is within scope.
|
||||
if granted := attr.Contains(resource.String()); !granted {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if permission is within scope.
|
||||
if len(perms) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if scope is limited to read or write operations.
|
||||
if a := attr.Find(acl.ScopeRead.String()); a.Value == list.True && acl.GrantScopeRead.DenyAny(perms) {
|
||||
return false
|
||||
} else if a = attr.Find(acl.ScopeWrite.String()); a.Value == list.True && acl.GrantScopeWrite.DenyAny(perms) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return acl.ScopePermits(m.AuthScope, resource, perms)
|
||||
}
|
||||
|
||||
// InsufficientScope checks if the scope does not include access to specified resource.
|
||||
|
@@ -219,6 +219,10 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
|
||||
updates["ClusterUUID"] = r.UUID
|
||||
}
|
||||
|
||||
if cidr := strings.TrimSpace(r.ClusterCIDR); cidr != "" {
|
||||
updates["ClusterCIDR"] = cidr
|
||||
}
|
||||
|
||||
// Always persist NodeClientID (client UID) from response for future OAuth token requests.
|
||||
if r.Node.ClientID != "" {
|
||||
updates["NodeClientID"] = r.Node.ClientID
|
||||
|
@@ -37,10 +37,11 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
UUID: rnd.UUID(),
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
JWKSUrl: jwksURL,
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
UUID: rnd.UUID(),
|
||||
ClusterCIDR: "192.0.2.0/24",
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
JWKSUrl: jwksURL,
|
||||
Database: cluster.RegisterDatabase{
|
||||
Driver: config.MySQL,
|
||||
Host: "db.local",
|
||||
@@ -84,6 +85,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
|
||||
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
|
||||
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
|
||||
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
|
||||
assert.Equal(t, "192.0.2.0/24", c.ClusterCIDR())
|
||||
}
|
||||
|
||||
func TestThemeInstall_Missing(t *testing.T) {
|
||||
@@ -101,7 +103,7 @@ func TestThemeInstall_Missing(t *testing.T) {
|
||||
case "/api/v1/cluster/nodes/register":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
|
||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), ClusterCIDR: "198.51.100.0/24", Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
|
||||
case "/api/v1/oauth/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"})
|
||||
@@ -148,10 +150,11 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
resp := cluster.RegisterResponse{
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
JWKSUrl: jwksURL3,
|
||||
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
|
||||
Node: cluster.Node{Name: "pp-node-01"},
|
||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||
ClusterCIDR: "203.0.113.0/24",
|
||||
JWKSUrl: jwksURL3,
|
||||
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
default:
|
||||
@@ -179,6 +182,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
||||
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
|
||||
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
|
||||
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
|
||||
assert.Equal(t, "203.0.113.0/24", c.ClusterCIDR())
|
||||
}
|
||||
|
||||
func TestRegister_404_NoRetry(t *testing.T) {
|
||||
|
@@ -35,10 +35,11 @@ type DatabaseInfo struct {
|
||||
// SummaryResponse is the response type for GET /api/v1/cluster.
|
||||
// swagger:model SummaryResponse
|
||||
type SummaryResponse struct {
|
||||
UUID string `json:"uuid"` // ClusterUUID
|
||||
Nodes int `json:"nodes"`
|
||||
Database DatabaseInfo `json:"database"`
|
||||
Time string `json:"time"`
|
||||
UUID string `json:"uuid"` // ClusterUUID
|
||||
ClusterCIDR string `json:"clusterCidr,omitempty"`
|
||||
Nodes int `json:"nodes"`
|
||||
Database DatabaseInfo `json:"database"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// RegisterSecrets contains newly issued or rotated node secrets.
|
||||
@@ -65,6 +66,7 @@ type RegisterDatabase struct {
|
||||
// swagger:model RegisterResponse
|
||||
type RegisterResponse struct {
|
||||
UUID string `json:"uuid"` // ClusterUUID
|
||||
ClusterCIDR string `json:"clusterCidr,omitempty"`
|
||||
Node Node `json:"node"`
|
||||
Database RegisterDatabase `json:"database"`
|
||||
Secrets *RegisterSecrets `json:"secrets,omitempty"`
|
||||
|
@@ -6,7 +6,8 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/list"
|
||||
)
|
||||
|
||||
// Scope sanitizes a string that contains authentication scope identifiers.
|
||||
// Scope sanitizes a string that contains auth scope identifiers.
|
||||
// Callers should use acl.ScopeAttrPermits / acl.ScopePermits for authorization checks.
|
||||
func Scope(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
@@ -15,7 +16,8 @@ func Scope(s string) string {
|
||||
return list.ParseAttr(strings.ToLower(s)).String()
|
||||
}
|
||||
|
||||
// Scopes sanitizes authentication scope identifiers and returns them as string slice.
|
||||
// Scopes sanitizes auth scope identifiers and returns them as strings.
|
||||
// Callers should use acl.ScopeAttrPermits / acl.ScopePermits for authorization checks.
|
||||
func Scopes(s string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
|
@@ -74,7 +74,7 @@ func (f *KeyValue) Parse(s string) *KeyValue {
|
||||
}
|
||||
|
||||
// Default?
|
||||
if f.Key == All {
|
||||
if f.Key == Any {
|
||||
return f
|
||||
} else if v = Value(v); v == "" {
|
||||
f.Value = True
|
||||
@@ -97,8 +97,8 @@ func (f *KeyValue) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
if f.Key == All {
|
||||
return All
|
||||
if f.Key == Any {
|
||||
return Any
|
||||
}
|
||||
|
||||
if Bool[strings.ToLower(f.Value)] == True {
|
||||
@@ -111,3 +111,8 @@ func (f *KeyValue) String() string {
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Any checks if this represents any value (asterisk).
|
||||
func (f *KeyValue) Any() bool {
|
||||
return f.Key == Any
|
||||
}
|
||||
|
@@ -68,9 +68,9 @@ func (list Attr) Sort() Attr {
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].Key == list[j].Key {
|
||||
return list[i].Value < list[j].Value
|
||||
} else if list[i].Key == All {
|
||||
} else if list[i].Key == Any {
|
||||
return false
|
||||
} else if list[j].Key == All {
|
||||
} else if list[j].Key == Any {
|
||||
return true
|
||||
} else {
|
||||
return list[i].Key < list[j].Key
|
||||
@@ -95,23 +95,25 @@ func (list Attr) Contains(s string) bool {
|
||||
func (list Attr) Find(s string) (a KeyValue) {
|
||||
if len(list) == 0 || s == "" {
|
||||
return a
|
||||
} else if s == All {
|
||||
return KeyValue{Key: All, Value: ""}
|
||||
} else if s == Any {
|
||||
return KeyValue{Key: Any, Value: ""}
|
||||
}
|
||||
|
||||
attr := ParseKeyValue(s)
|
||||
|
||||
// Return nil if key is invalid or all.
|
||||
if attr.Key == "" {
|
||||
// Return if key is invalid.
|
||||
if attr == nil {
|
||||
return a
|
||||
} else if attr.Key == "" {
|
||||
return a
|
||||
}
|
||||
|
||||
// Find and return first match.
|
||||
if attr.Value == "" || attr.Value == All {
|
||||
if attr.Value == "" || attr.Value == Any {
|
||||
for i := range list {
|
||||
if strings.EqualFold(attr.Key, list[i].Key) {
|
||||
return *list[i]
|
||||
} else if list[i].Key == All {
|
||||
} else if list[i].Key == Any {
|
||||
a = *list[i]
|
||||
}
|
||||
}
|
||||
@@ -122,10 +124,10 @@ func (list Attr) Find(s string) (a KeyValue) {
|
||||
return KeyValue{Key: "", Value: ""}
|
||||
} else if attr.Value == list[i].Value {
|
||||
return *list[i]
|
||||
} else if list[i].Value == All {
|
||||
} else if list[i].Value == Any {
|
||||
a = *list[i]
|
||||
}
|
||||
} else if list[i].Key == All && attr.Value != False {
|
||||
} else if list[i].Key == Any && attr.Value != False {
|
||||
a = *list[i]
|
||||
}
|
||||
}
|
||||
|
@@ -164,7 +164,7 @@ func TestAttr_Find(t *testing.T) {
|
||||
assert.Len(t, attr, 1)
|
||||
result := attr.Find("metrics")
|
||||
|
||||
assert.Equal(t, All, result.Key)
|
||||
assert.Equal(t, Any, result.Key)
|
||||
assert.Equal(t, "", result.Value)
|
||||
})
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
@@ -182,6 +182,7 @@ func TestAttr_Find(t *testing.T) {
|
||||
|
||||
assert.Len(t, attr, 1)
|
||||
result := attr.Find("*")
|
||||
assert.Equal(t, Any, result.Key)
|
||||
assert.Equal(t, All, result.Key)
|
||||
assert.Equal(t, "", result.Value)
|
||||
})
|
||||
@@ -191,6 +192,7 @@ func TestAttr_Find(t *testing.T) {
|
||||
|
||||
assert.Len(t, attr, 1)
|
||||
result := attr.Find("6VU:*")
|
||||
assert.Equal(t, Any, result.Key)
|
||||
assert.Equal(t, All, result.Key)
|
||||
assert.Equal(t, "", result.Value)
|
||||
})
|
||||
@@ -230,7 +232,7 @@ func TestAttr_Find(t *testing.T) {
|
||||
assert.Len(t, attr, 2)
|
||||
|
||||
result := attr.Find("read")
|
||||
assert.Equal(t, All, result.Key)
|
||||
assert.Equal(t, Any, result.Key)
|
||||
assert.Equal(t, "", result.Value)
|
||||
|
||||
result = attr.Find("read:other")
|
||||
@@ -238,7 +240,7 @@ func TestAttr_Find(t *testing.T) {
|
||||
assert.Equal(t, "other", result.Value)
|
||||
|
||||
result = attr.Find("read:true")
|
||||
assert.Equal(t, All, result.Key)
|
||||
assert.Equal(t, Any, result.Key)
|
||||
assert.Equal(t, "", result.Value)
|
||||
|
||||
result = attr.Find("read:false")
|
||||
|
@@ -1,18 +1,22 @@
|
||||
package list
|
||||
|
||||
const All = "*"
|
||||
// Any matches everything.
|
||||
const Any = "*"
|
||||
|
||||
// All is kept for backward compatibility, but deprecated.
|
||||
const All = Any
|
||||
|
||||
// Contains tests if a string is contained in the list.
|
||||
func Contains(list []string, s string) bool {
|
||||
if len(list) == 0 || s == "" {
|
||||
return false
|
||||
} else if s == All {
|
||||
} else if s == Any {
|
||||
return true
|
||||
}
|
||||
|
||||
// Find matches.
|
||||
for i := range list {
|
||||
if s == list[i] || list[i] == All {
|
||||
if s == list[i] || list[i] == Any {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -27,11 +31,11 @@ func ContainsAny(l, s []string) bool {
|
||||
}
|
||||
|
||||
// If second list contains All, it's a wildcard match.
|
||||
if s[0] == All {
|
||||
if s[0] == Any {
|
||||
return true
|
||||
}
|
||||
for j := 1; j < len(s); j++ {
|
||||
if s[j] == All {
|
||||
if s[j] == Any {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ package list
|
||||
func Remove(list []string, s string) []string {
|
||||
if len(list) == 0 || s == "" {
|
||||
return list
|
||||
} else if s == All {
|
||||
} else if s == Any {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user