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.
|
- 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.
|
- 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.
|
- 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
|
### Import/Index
|
||||||
|
|
||||||
|
@@ -80,7 +80,7 @@ Database & Migrations
|
|||||||
|
|
||||||
AuthN/Z & Sessions
|
AuthN/Z & Sessions
|
||||||
- Session model and cache: `internal/entity/auth_session*` and `internal/auth/session/*` (cleanup worker).
|
- 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/*`.
|
- OIDC: `internal/auth/oidc/*`.
|
||||||
|
|
||||||
Media Processing
|
Media Processing
|
||||||
|
18
Makefile
18
Makefile
@@ -72,15 +72,15 @@ watch: watch-js
|
|||||||
build-all: build-go build-js
|
build-all: build-go build-js
|
||||||
pull: docker-pull
|
pull: docker-pull
|
||||||
test: test-js test-go
|
test: test-js test-go
|
||||||
test-go: reset-sqlite run-test-go
|
test-go: run-test-go
|
||||||
test-pkg: reset-sqlite run-test-pkg
|
test-pkg: run-test-pkg
|
||||||
test-ai: reset-sqlite run-test-ai
|
test-ai: run-test-ai
|
||||||
test-api: reset-sqlite run-test-api
|
test-api: run-test-api
|
||||||
test-video: reset-sqlite run-test-video
|
test-video: run-test-video
|
||||||
test-entity: reset-sqlite run-test-entity
|
test-entity: run-test-entity
|
||||||
test-commands: reset-sqlite run-test-commands
|
test-commands: run-test-commands
|
||||||
test-photoprism: reset-sqlite run-test-photoprism
|
test-photoprism: run-test-photoprism
|
||||||
test-short: reset-sqlite run-test-short
|
test-short: run-test-short
|
||||||
test-mariadb: reset-acceptance run-test-mariadb
|
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: 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
|
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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,78 +18,132 @@ import (
|
|||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
)
|
)
|
||||||
|
|
||||||
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster
|
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster node
|
||||||
// node receives a request without an existing session. It verifies the token
|
// receives a request without an existing session. It verifies the token against
|
||||||
// against the node's cached JWKS, ensures the issuer/audience/scope match the
|
// the node's cached JWKS, ensures the issuer/audience/scope match the expected
|
||||||
// expected portal values, and, if valid, returns a client session mirroring the
|
// portal values, and, if valid, returns a client session mirroring the JWT
|
||||||
// JWT claims. It returns nil on any validation failure so the caller can fall
|
// claims. It returns nil on any validation failure so the caller can fall back
|
||||||
// back to existing auth flows. Currently cluster and vision resources are
|
// to existing auth flows. By default, only cluster and vision resources are
|
||||||
// eligible for JWT-based authorization; vision access requires the `vision`
|
// eligible, but nodes may opt in to additional scopes via PHOTOPRISM_JWT_SCOPE.
|
||||||
// scope whereas cluster access requires the `cluster` scope.
|
|
||||||
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
|
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
|
||||||
if c == nil || authToken == "" {
|
// Check if token may be a JWT.
|
||||||
return nil
|
if !shouldAttemptJWT(c, authToken) {
|
||||||
}
|
|
||||||
|
|
||||||
_ = perms
|
|
||||||
|
|
||||||
if resource != acl.ResourceCluster && resource != acl.ResourceVision {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic sanity check for JWT structure.
|
|
||||||
if strings.Count(authToken, ".") != 2 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
conf := get.Config()
|
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
|
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() == "" {
|
if conf.JWKSUrl() == "" {
|
||||||
return nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
requiredScopes := []string{"cluster"}
|
cidr := strings.TrimSpace(conf.ClusterCIDR())
|
||||||
if resource == acl.ResourceVision {
|
if cidr == "" {
|
||||||
requiredScopes = []string{"vision"}
|
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{
|
expected := clusterjwt.ExpectedClaims{
|
||||||
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
|
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
|
||||||
Scope: requiredScopes,
|
|
||||||
JWKSURL: conf.JWKSUrl(),
|
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 {
|
if len(issuers) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
claims *clusterjwt.Claims
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
for _, issuer := range issuers {
|
for _, issuer := range issuers {
|
||||||
expected.Issuer = issuer
|
expected.Issuer = issuer
|
||||||
claims, err = get.VerifyJWT(ctx, authToken, expected)
|
claims, err := get.VerifyJWT(ctx, token, expected)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
return claims
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
return nil
|
||||||
return nil
|
}
|
||||||
} else if claims == 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{
|
sess := &entity.Session{
|
||||||
Status: http.StatusOK,
|
Status: http.StatusOK,
|
||||||
ClientUID: claims.Subject,
|
ClientUID: claims.Subject,
|
||||||
|
@@ -1,17 +1,22 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
gojwt "github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
"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/config"
|
||||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||||
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAuthAnyJWT(t *testing.T) {
|
func TestAuthAnyJWT(t *testing.T) {
|
||||||
@@ -35,7 +40,149 @@ func TestAuthAnyJWT(t *testing.T) {
|
|||||||
assert.Contains(t, session.AuthScope, "cluster")
|
assert.Contains(t, session.AuthScope, "cluster")
|
||||||
assert.Equal(t, spec.Issuer, session.AuthIssuer)
|
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) {
|
t.Run("VisionScope", func(t *testing.T) {
|
||||||
fx := newPortalJWTFixture(t, "cluster-jwt-vision")
|
fx := newPortalJWTFixture(t, "cluster-jwt-vision")
|
||||||
spec := fx.defaultClaimsSpec()
|
spec := fx.defaultClaimsSpec()
|
||||||
@@ -146,3 +293,83 @@ func TestJwtIssuerCandidates(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"http://localhost:2342"}, jwtIssuerCandidates(conf))
|
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{
|
resp := cluster.RegisterResponse{
|
||||||
UUID: conf.ClusterUUID(),
|
UUID: conf.ClusterUUID(),
|
||||||
|
ClusterCIDR: conf.ClusterCIDR(),
|
||||||
Node: reg.BuildClusterNode(*n, opts),
|
Node: reg.BuildClusterNode(*n, opts),
|
||||||
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
|
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
|
||||||
Secrets: respSecret,
|
Secrets: respSecret,
|
||||||
@@ -299,6 +300,8 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := cluster.RegisterResponse{
|
resp := cluster.RegisterResponse{
|
||||||
|
UUID: conf.ClusterUUID(),
|
||||||
|
ClusterCIDR: conf.ClusterCIDR(),
|
||||||
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
|
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
|
||||||
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
|
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},
|
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()
|
nodes, _ := regy.List()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, cluster.SummaryResponse{
|
c.JSON(http.StatusOK, cluster.SummaryResponse{
|
||||||
UUID: conf.ClusterUUID(),
|
UUID: conf.ClusterUUID(),
|
||||||
Nodes: len(nodes),
|
ClusterCIDR: conf.ClusterCIDR(),
|
||||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
Nodes: len(nodes),
|
||||||
Time: time.Now().UTC().Format(time.RFC3339),
|
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||||
|
Time: time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -419,6 +419,9 @@
|
|||||||
"alreadyRegistered": {
|
"alreadyRegistered": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"clusterCidr": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"$ref": "#/definitions/cluster.RegisterDatabase"
|
"$ref": "#/definitions/cluster.RegisterDatabase"
|
||||||
},
|
},
|
||||||
@@ -459,6 +462,9 @@
|
|||||||
},
|
},
|
||||||
"cluster.SummaryResponse": {
|
"cluster.SummaryResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"clusterCidr": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"$ref": "#/definitions/cluster.DatabaseInfo"
|
"$ref": "#/definitions/cluster.DatabaseInfo"
|
||||||
},
|
},
|
||||||
|
@@ -1,5 +1,11 @@
|
|||||||
package acl
|
package acl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/list"
|
||||||
|
)
|
||||||
|
|
||||||
// Permission scopes to Grant multiple Permissions for a Resource.
|
// Permission scopes to Grant multiple Permissions for a Resource.
|
||||||
const (
|
const (
|
||||||
ScopeRead Permission = "read"
|
ScopeRead Permission = "read"
|
||||||
@@ -35,3 +41,63 @@ var (
|
|||||||
ActionManageOwn: true,
|
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}))
|
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.
|
// Set a default client name if no specific name has been provided.
|
||||||
if frm.AuthScope == "" {
|
if frm.AuthScope == "" {
|
||||||
frm.AuthScope = list.All
|
frm.AuthScope = list.Any
|
||||||
}
|
}
|
||||||
|
|
||||||
client, addErr := entity.AddClient(frm)
|
client, addErr := entity.AddClient(frm)
|
||||||
|
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"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/service/http/header"
|
||||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||||
)
|
)
|
||||||
@@ -330,6 +331,16 @@ func parseLabelSlice(labels []string) map[string]string {
|
|||||||
|
|
||||||
// Persistence helpers for --write-config
|
// Persistence helpers for --write-config
|
||||||
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
|
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
|
// Node client secret file
|
||||||
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
if resp.Secrets != nil && resp.Secrets.ClientSecret != "" {
|
||||||
// Prefer PHOTOPRISM_NODE_CLIENT_SECRET_FILE; otherwise config cluster path
|
// 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)
|
// DB settings (MySQL/MariaDB only)
|
||||||
if resp.Database.Name != "" && resp.Database.User != "" {
|
if resp.Database.Name != "" && resp.Database.User != "" {
|
||||||
if err := mergeOptionsYaml(conf, map[string]any{
|
updates["DatabaseDriver"] = config.MySQL
|
||||||
"DatabaseDriver": config.MySQL,
|
updates["DatabaseName"] = resp.Database.Name
|
||||||
"DatabaseName": resp.Database.Name,
|
updates["DatabaseServer"] = fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port)
|
||||||
"DatabaseServer": fmt.Sprintf("%s:%d", resp.Database.Host, resp.Database.Port),
|
updates["DatabaseUser"] = resp.Database.User
|
||||||
"DatabaseUser": resp.Database.User,
|
updates["DatabasePassword"] = resp.Database.Password
|
||||||
"DatabasePassword": resp.Database.Password,
|
}
|
||||||
}); err != nil {
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := mergeOptionsYaml(conf, updates); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -36,10 +36,11 @@ func clusterSummaryAction(ctx *cli.Context) error {
|
|||||||
nodes, _ := r.List()
|
nodes, _ := r.List()
|
||||||
|
|
||||||
resp := cluster.SummaryResponse{
|
resp := cluster.SummaryResponse{
|
||||||
UUID: conf.ClusterUUID(),
|
UUID: conf.ClusterUUID(),
|
||||||
Nodes: len(nodes),
|
ClusterCIDR: conf.ClusterCIDR(),
|
||||||
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
Nodes: len(nodes),
|
||||||
Time: time.Now().UTC().Format(time.RFC3339),
|
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
|
||||||
|
Time: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Bool("json") {
|
if ctx.Bool("json") {
|
||||||
@@ -48,8 +49,8 @@ func clusterSummaryAction(ctx *cli.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cols := []string{"Portal UUID", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
|
cols := []string{"Portal UUID", "Cluster CIDR", "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}}
|
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))
|
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||||
fmt.Printf("\n%s\n", out)
|
fmt.Printf("\n%s\n", out)
|
||||||
return err
|
return err
|
||||||
|
@@ -95,9 +95,10 @@ func TestClusterThemePull_JoinTokenToOAuth(t *testing.T) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
// Return NodeClientID and a fresh secret
|
// Return NodeClientID and a fresh secret
|
||||||
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
|
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{
|
||||||
UUID: rnd.UUID(),
|
UUID: rnd.UUID(),
|
||||||
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
|
ClusterCIDR: "203.0.113.0/24",
|
||||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
|
Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"},
|
||||||
|
Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"},
|
||||||
})
|
})
|
||||||
case "/api/v1/oauth/token":
|
case "/api/v1/oauth/token":
|
||||||
// Expect Basic for the returned creds
|
// Expect Basic for the returned creds
|
||||||
|
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
|
"github.com/photoprism/photoprism/pkg/list"
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"github.com/photoprism/photoprism/pkg/rnd"
|
||||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||||
)
|
)
|
||||||
@@ -278,6 +279,18 @@ func (c *Config) JWTLeeway() int {
|
|||||||
return c.options.JWTLeeway
|
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]).
|
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
|
||||||
func (c *Config) AdvertiseUrl() string {
|
func (c *Config) AdvertiseUrl() string {
|
||||||
if c.options.AdvertiseUrl != "" {
|
if c.options.AdvertiseUrl != "" {
|
||||||
|
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/service/cluster"
|
"github.com/photoprism/photoprism/internal/service/cluster"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
|
"github.com/photoprism/photoprism/pkg/list"
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"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) {
|
t.Run("Paths", func(t *testing.T) {
|
||||||
c := NewConfig(CliTestContext())
|
c := NewConfig(CliTestContext())
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// ApplyScope updates the current settings based on the authorization scope passed.
|
// ApplyScope updates the current settings based on the authorization scope passed.
|
||||||
func (s *Settings) ApplyScope(scope string) *Settings {
|
func (s *Settings) ApplyScope(scope string) *Settings {
|
||||||
if scope == "" || scope == list.All {
|
if scope == "" || scope == list.Any {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -731,6 +731,11 @@ var Flags = CliFlags{
|
|||||||
Value: 300,
|
Value: 300,
|
||||||
EnvVars: EnvVars("JWKS_CACHE_TTL"),
|
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{
|
Flag: &cli.IntFlag{
|
||||||
Name: "jwt-leeway",
|
Name: "jwt-leeway",
|
||||||
Usage: "JWT clock skew allowance in `SECONDS` (default 60, max 300)",
|
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"`
|
NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"`
|
||||||
JWKSUrl string `yaml:"JWKSUrl" json:"-" flag:"jwks-url"`
|
JWKSUrl string `yaml:"JWKSUrl" json:"-" flag:"jwks-url"`
|
||||||
JWKSCacheTTL int `yaml:"JWKSCacheTTL" json:"-" flag:"jwks-cache-ttl"`
|
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"`
|
JWTLeeway int `yaml:"JWTLeeway" json:"-" flag:"jwt-leeway"`
|
||||||
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
|
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
|
||||||
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
|
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())))},
|
{"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))},
|
||||||
{"jwks-url", c.JWKSUrl()},
|
{"jwks-url", c.JWKSUrl()},
|
||||||
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
|
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
|
||||||
|
{"jwt-scope", c.JWTAllowedScopes().String()},
|
||||||
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
|
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
|
||||||
{"advertise-url", c.AdvertiseUrl()},
|
{"advertise-url", c.AdvertiseUrl()},
|
||||||
|
|
||||||
|
@@ -17,7 +17,6 @@ import (
|
|||||||
"github.com/photoprism/photoprism/pkg/authn"
|
"github.com/photoprism/photoprism/pkg/authn"
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
"github.com/photoprism/photoprism/pkg/i18n"
|
"github.com/photoprism/photoprism/pkg/i18n"
|
||||||
"github.com/photoprism/photoprism/pkg/list"
|
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"github.com/photoprism/photoprism/pkg/rnd"
|
||||||
"github.com/photoprism/photoprism/pkg/service/http/header"
|
"github.com/photoprism/photoprism/pkg/service/http/header"
|
||||||
"github.com/photoprism/photoprism/pkg/time/unix"
|
"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.
|
// ValidateScope checks if the scope does not exclude access to specified resource.
|
||||||
func (m *Session) ValidateScope(resource acl.Resource, perms acl.Permissions) bool {
|
func (m *Session) ValidateScope(resource acl.Resource, perms acl.Permissions) bool {
|
||||||
// Get scope string.
|
return acl.ScopePermits(m.AuthScope, resource, perms)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsufficientScope checks if the scope does not include access to specified resource.
|
// 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
|
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.
|
// Always persist NodeClientID (client UID) from response for future OAuth token requests.
|
||||||
if r.Node.ClientID != "" {
|
if r.Node.ClientID != "" {
|
||||||
updates["NodeClientID"] = 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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
resp := cluster.RegisterResponse{
|
resp := cluster.RegisterResponse{
|
||||||
Node: cluster.Node{Name: "pp-node-01"},
|
Node: cluster.Node{Name: "pp-node-01"},
|
||||||
UUID: rnd.UUID(),
|
UUID: rnd.UUID(),
|
||||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
ClusterCIDR: "192.0.2.0/24",
|
||||||
JWKSUrl: jwksURL,
|
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||||
|
JWKSUrl: jwksURL,
|
||||||
Database: cluster.RegisterDatabase{
|
Database: cluster.RegisterDatabase{
|
||||||
Driver: config.MySQL,
|
Driver: config.MySQL,
|
||||||
Host: "db.local",
|
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.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
|
||||||
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
|
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
|
||||||
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
|
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) {
|
func TestThemeInstall_Missing(t *testing.T) {
|
||||||
@@ -101,7 +103,7 @@ func TestThemeInstall_Missing(t *testing.T) {
|
|||||||
case "/api/v1/cluster/nodes/register":
|
case "/api/v1/cluster/nodes/register":
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
|
// 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":
|
case "/api/v1/oauth/token":
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"})
|
_ = 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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
resp := cluster.RegisterResponse{
|
resp := cluster.RegisterResponse{
|
||||||
Node: cluster.Node{Name: "pp-node-01"},
|
Node: cluster.Node{Name: "pp-node-01"},
|
||||||
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
|
||||||
JWKSUrl: jwksURL3,
|
ClusterCIDR: "203.0.113.0/24",
|
||||||
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"},
|
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)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
default:
|
default:
|
||||||
@@ -179,6 +182,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
|
|||||||
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
|
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
|
||||||
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
|
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
|
||||||
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
|
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) {
|
func TestRegister_404_NoRetry(t *testing.T) {
|
||||||
|
@@ -35,10 +35,11 @@ type DatabaseInfo struct {
|
|||||||
// SummaryResponse is the response type for GET /api/v1/cluster.
|
// SummaryResponse is the response type for GET /api/v1/cluster.
|
||||||
// swagger:model SummaryResponse
|
// swagger:model SummaryResponse
|
||||||
type SummaryResponse struct {
|
type SummaryResponse struct {
|
||||||
UUID string `json:"uuid"` // ClusterUUID
|
UUID string `json:"uuid"` // ClusterUUID
|
||||||
Nodes int `json:"nodes"`
|
ClusterCIDR string `json:"clusterCidr,omitempty"`
|
||||||
Database DatabaseInfo `json:"database"`
|
Nodes int `json:"nodes"`
|
||||||
Time string `json:"time"`
|
Database DatabaseInfo `json:"database"`
|
||||||
|
Time string `json:"time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterSecrets contains newly issued or rotated node secrets.
|
// RegisterSecrets contains newly issued or rotated node secrets.
|
||||||
@@ -65,6 +66,7 @@ type RegisterDatabase struct {
|
|||||||
// swagger:model RegisterResponse
|
// swagger:model RegisterResponse
|
||||||
type RegisterResponse struct {
|
type RegisterResponse struct {
|
||||||
UUID string `json:"uuid"` // ClusterUUID
|
UUID string `json:"uuid"` // ClusterUUID
|
||||||
|
ClusterCIDR string `json:"clusterCidr,omitempty"`
|
||||||
Node Node `json:"node"`
|
Node Node `json:"node"`
|
||||||
Database RegisterDatabase `json:"database"`
|
Database RegisterDatabase `json:"database"`
|
||||||
Secrets *RegisterSecrets `json:"secrets,omitempty"`
|
Secrets *RegisterSecrets `json:"secrets,omitempty"`
|
||||||
|
@@ -6,7 +6,8 @@ import (
|
|||||||
"github.com/photoprism/photoprism/pkg/list"
|
"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 {
|
func Scope(s string) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -15,7 +16,8 @@ func Scope(s string) string {
|
|||||||
return list.ParseAttr(strings.ToLower(s)).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 {
|
func Scopes(s string) []string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return []string{}
|
return []string{}
|
||||||
|
@@ -74,7 +74,7 @@ func (f *KeyValue) Parse(s string) *KeyValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default?
|
// Default?
|
||||||
if f.Key == All {
|
if f.Key == Any {
|
||||||
return f
|
return f
|
||||||
} else if v = Value(v); v == "" {
|
} else if v = Value(v); v == "" {
|
||||||
f.Value = True
|
f.Value = True
|
||||||
@@ -97,8 +97,8 @@ func (f *KeyValue) String() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.Key == All {
|
if f.Key == Any {
|
||||||
return All
|
return Any
|
||||||
}
|
}
|
||||||
|
|
||||||
if Bool[strings.ToLower(f.Value)] == True {
|
if Bool[strings.ToLower(f.Value)] == True {
|
||||||
@@ -111,3 +111,8 @@ func (f *KeyValue) String() string {
|
|||||||
|
|
||||||
return ""
|
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 {
|
sort.Slice(list, func(i, j int) bool {
|
||||||
if list[i].Key == list[j].Key {
|
if list[i].Key == list[j].Key {
|
||||||
return list[i].Value < list[j].Value
|
return list[i].Value < list[j].Value
|
||||||
} else if list[i].Key == All {
|
} else if list[i].Key == Any {
|
||||||
return false
|
return false
|
||||||
} else if list[j].Key == All {
|
} else if list[j].Key == Any {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return list[i].Key < list[j].Key
|
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) {
|
func (list Attr) Find(s string) (a KeyValue) {
|
||||||
if len(list) == 0 || s == "" {
|
if len(list) == 0 || s == "" {
|
||||||
return a
|
return a
|
||||||
} else if s == All {
|
} else if s == Any {
|
||||||
return KeyValue{Key: All, Value: ""}
|
return KeyValue{Key: Any, Value: ""}
|
||||||
}
|
}
|
||||||
|
|
||||||
attr := ParseKeyValue(s)
|
attr := ParseKeyValue(s)
|
||||||
|
|
||||||
// Return nil if key is invalid or all.
|
// Return if key is invalid.
|
||||||
if attr.Key == "" {
|
if attr == nil {
|
||||||
|
return a
|
||||||
|
} else if attr.Key == "" {
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and return first match.
|
// Find and return first match.
|
||||||
if attr.Value == "" || attr.Value == All {
|
if attr.Value == "" || attr.Value == Any {
|
||||||
for i := range list {
|
for i := range list {
|
||||||
if strings.EqualFold(attr.Key, list[i].Key) {
|
if strings.EqualFold(attr.Key, list[i].Key) {
|
||||||
return *list[i]
|
return *list[i]
|
||||||
} else if list[i].Key == All {
|
} else if list[i].Key == Any {
|
||||||
a = *list[i]
|
a = *list[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,10 +124,10 @@ func (list Attr) Find(s string) (a KeyValue) {
|
|||||||
return KeyValue{Key: "", Value: ""}
|
return KeyValue{Key: "", Value: ""}
|
||||||
} else if attr.Value == list[i].Value {
|
} else if attr.Value == list[i].Value {
|
||||||
return *list[i]
|
return *list[i]
|
||||||
} else if list[i].Value == All {
|
} else if list[i].Value == Any {
|
||||||
a = *list[i]
|
a = *list[i]
|
||||||
}
|
}
|
||||||
} else if list[i].Key == All && attr.Value != False {
|
} else if list[i].Key == Any && attr.Value != False {
|
||||||
a = *list[i]
|
a = *list[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -164,7 +164,7 @@ func TestAttr_Find(t *testing.T) {
|
|||||||
assert.Len(t, attr, 1)
|
assert.Len(t, attr, 1)
|
||||||
result := attr.Find("metrics")
|
result := attr.Find("metrics")
|
||||||
|
|
||||||
assert.Equal(t, All, result.Key)
|
assert.Equal(t, Any, result.Key)
|
||||||
assert.Equal(t, "", result.Value)
|
assert.Equal(t, "", result.Value)
|
||||||
})
|
})
|
||||||
t.Run("Empty", func(t *testing.T) {
|
t.Run("Empty", func(t *testing.T) {
|
||||||
@@ -182,6 +182,7 @@ func TestAttr_Find(t *testing.T) {
|
|||||||
|
|
||||||
assert.Len(t, attr, 1)
|
assert.Len(t, attr, 1)
|
||||||
result := attr.Find("*")
|
result := attr.Find("*")
|
||||||
|
assert.Equal(t, Any, result.Key)
|
||||||
assert.Equal(t, All, result.Key)
|
assert.Equal(t, All, result.Key)
|
||||||
assert.Equal(t, "", result.Value)
|
assert.Equal(t, "", result.Value)
|
||||||
})
|
})
|
||||||
@@ -191,6 +192,7 @@ func TestAttr_Find(t *testing.T) {
|
|||||||
|
|
||||||
assert.Len(t, attr, 1)
|
assert.Len(t, attr, 1)
|
||||||
result := attr.Find("6VU:*")
|
result := attr.Find("6VU:*")
|
||||||
|
assert.Equal(t, Any, result.Key)
|
||||||
assert.Equal(t, All, result.Key)
|
assert.Equal(t, All, result.Key)
|
||||||
assert.Equal(t, "", result.Value)
|
assert.Equal(t, "", result.Value)
|
||||||
})
|
})
|
||||||
@@ -230,7 +232,7 @@ func TestAttr_Find(t *testing.T) {
|
|||||||
assert.Len(t, attr, 2)
|
assert.Len(t, attr, 2)
|
||||||
|
|
||||||
result := attr.Find("read")
|
result := attr.Find("read")
|
||||||
assert.Equal(t, All, result.Key)
|
assert.Equal(t, Any, result.Key)
|
||||||
assert.Equal(t, "", result.Value)
|
assert.Equal(t, "", result.Value)
|
||||||
|
|
||||||
result = attr.Find("read:other")
|
result = attr.Find("read:other")
|
||||||
@@ -238,7 +240,7 @@ func TestAttr_Find(t *testing.T) {
|
|||||||
assert.Equal(t, "other", result.Value)
|
assert.Equal(t, "other", result.Value)
|
||||||
|
|
||||||
result = attr.Find("read:true")
|
result = attr.Find("read:true")
|
||||||
assert.Equal(t, All, result.Key)
|
assert.Equal(t, Any, result.Key)
|
||||||
assert.Equal(t, "", result.Value)
|
assert.Equal(t, "", result.Value)
|
||||||
|
|
||||||
result = attr.Find("read:false")
|
result = attr.Find("read:false")
|
||||||
|
@@ -1,18 +1,22 @@
|
|||||||
package list
|
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.
|
// Contains tests if a string is contained in the list.
|
||||||
func Contains(list []string, s string) bool {
|
func Contains(list []string, s string) bool {
|
||||||
if len(list) == 0 || s == "" {
|
if len(list) == 0 || s == "" {
|
||||||
return false
|
return false
|
||||||
} else if s == All {
|
} else if s == Any {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matches.
|
// Find matches.
|
||||||
for i := range list {
|
for i := range list {
|
||||||
if s == list[i] || list[i] == All {
|
if s == list[i] || list[i] == Any {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,11 +31,11 @@ func ContainsAny(l, s []string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If second list contains All, it's a wildcard match.
|
// If second list contains All, it's a wildcard match.
|
||||||
if s[0] == All {
|
if s[0] == Any {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for j := 1; j < len(s); j++ {
|
for j := 1; j < len(s); j++ {
|
||||||
if s[j] == All {
|
if s[j] == Any {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,7 @@ package list
|
|||||||
func Remove(list []string, s string) []string {
|
func Remove(list []string, s string) []string {
|
||||||
if len(list) == 0 || s == "" {
|
if len(list) == 0 || s == "" {
|
||||||
return list
|
return list
|
||||||
} else if s == All {
|
} else if s == Any {
|
||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user