feat(auth): integrate portal JWT for enhanced authentication and authorization
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 51s
CI / go (push) Successful in 2m19s
CI / bird2 (push) Successful in 13s
CI / release (push) Successful in 4m24s
Added support for portal JWT authentication, enabling single sign-on (SSO) capabilities. Updated the application to handle JWT claims for user permissions and roles, enhancing security and access control. Refactored relevant components and API routes to accommodate the new authentication flow, ensuring a seamless user experience. Updated documentation to reflect the new authentication requirements and configurations. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+128
-7
@@ -6,18 +6,32 @@ import (
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/authkey"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const authCtxKey ctxKey = 1
|
||||
|
||||
// Auth kinds distinguish API key sessions from portal JWT sessions.
|
||||
const (
|
||||
AuthKindAPIKey = "apikey"
|
||||
AuthKindJWT = "jwt"
|
||||
)
|
||||
|
||||
// Auth holds resolved API identity for a request.
|
||||
type Auth struct {
|
||||
TenantID string
|
||||
Role string // viewer, editor, operator, node
|
||||
Role string // viewer, editor, operator, node, firewall (apikeys only)
|
||||
Token string
|
||||
APIKeyID string // non-empty for DB-managed keys
|
||||
// Portal / dual-auth fields (empty for API keys unless noted).
|
||||
Kind string // "apikey" | "jwt"
|
||||
UserID string // JWT sub
|
||||
Email string // JWT email claim
|
||||
Permissions []string // JWT permissions claim (bgp:*)
|
||||
IsAdmin bool // JWT is_admin claim
|
||||
}
|
||||
|
||||
func authFromContext(ctx context.Context) (Auth, bool) {
|
||||
@@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
|
||||
return out
|
||||
}
|
||||
|
||||
// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty).
|
||||
func looksLikeJWT(raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
@@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||
if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" {
|
||||
a, status, msg, ok := s.resolveJWT(raw)
|
||||
if !ok {
|
||||
writeProblem(w, status, http.StatusText(status), msg)
|
||||
return
|
||||
}
|
||||
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
a, ok := s.resolveAuth(raw)
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
||||
@@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
|
||||
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
|
||||
return Auth{
|
||||
Kind: AuthKindAPIKey,
|
||||
TenantID: rec.tenantID,
|
||||
Role: rec.role,
|
||||
Token: raw,
|
||||
APIKeyID: rec.keyID,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAuth maps a bearer token to tenant identity.
|
||||
// resolveAuth maps a bearer token to tenant identity (API key path).
|
||||
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
|
||||
// is available; env/DB mapping is used only when demo tenant is absent.
|
||||
func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
@@ -99,23 +146,96 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||
if !ok {
|
||||
if s.firewallResolver != nil {
|
||||
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
||||
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||
}
|
||||
}
|
||||
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
|
||||
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||
}
|
||||
return Auth{}, false
|
||||
}
|
||||
return authFromKeyRecord(raw, rec), true
|
||||
}
|
||||
|
||||
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
|
||||
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
||||
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||
if strings.TrimSpace(s.portalTenantID) == "" {
|
||||
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false
|
||||
}
|
||||
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
if err != nil || tok == nil || !tok.Valid {
|
||||
return Auth{}, http.StatusUnauthorized, "invalid jwt", false
|
||||
}
|
||||
claims, ok := tok.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false
|
||||
}
|
||||
if iss := strings.TrimSpace(s.authIssuer); iss != "" {
|
||||
got, _ := claims["iss"].(string)
|
||||
if strings.TrimSpace(got) != iss {
|
||||
return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false
|
||||
}
|
||||
}
|
||||
apps := coerceStringSlice(claims["apps"])
|
||||
if !containsFold(apps, "bgp") {
|
||||
return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
if strings.TrimSpace(sub) == "" {
|
||||
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
|
||||
}
|
||||
email, _ := claims["email"].(string)
|
||||
perms := coerceStringSlice(claims["permissions"])
|
||||
isAdmin, _ := claims["is_admin"].(bool)
|
||||
return Auth{
|
||||
Kind: AuthKindJWT,
|
||||
TenantID: s.portalTenantID,
|
||||
UserID: strings.TrimSpace(sub),
|
||||
Email: strings.TrimSpace(email),
|
||||
Permissions: perms,
|
||||
IsAdmin: isAdmin,
|
||||
Token: raw,
|
||||
}, 0, "", true
|
||||
}
|
||||
|
||||
func coerceStringSlice(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, x := range t {
|
||||
if s, ok := x.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func containsFold(items []string, needle string) bool {
|
||||
for _, x := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(x), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) devAuth() (Auth, bool) {
|
||||
tid, _, _, _, _ := s.store.DemoIDs()
|
||||
if tid == "" {
|
||||
return Auth{}, false
|
||||
}
|
||||
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
|
||||
return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
|
||||
}
|
||||
|
||||
func roleLevel(role string) int {
|
||||
@@ -131,7 +251,8 @@ func roleLevel(role string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
|
||||
// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys.
|
||||
// New code should call requirePerm which supports JWT permissions in addition to API-key roles.
|
||||
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
|
||||
if strings.ToLower(a.Role) == "node" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
testJWTSecret = "test-secret-32-bytes-long-abcdef"
|
||||
testIssuer = "https://auth.test.local"
|
||||
)
|
||||
|
||||
func signTestJWT(t *testing.T, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, err := tok.SignedString([]byte(testJWTSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("sign jwt: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func newJWTTestServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
srv, err := New(Options{
|
||||
SeedDemo: true,
|
||||
BundleSeedHex: testBundleSeed,
|
||||
JWTSecret: testJWTSecret,
|
||||
AuthIssuer: testIssuer,
|
||||
AuthPortalURL: "https://portal.test.local",
|
||||
AuthRequired: true,
|
||||
PortalTenantID: "", // filled after DemoIDs
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||
// Override tenant to match seed.
|
||||
srv.portalTenantID = tenant
|
||||
return srv, tenant
|
||||
}
|
||||
|
||||
func TestAuthJWTAcceptedWithBGPApp(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"email": "[email protected]",
|
||||
"apps": []string{"bgp"},
|
||||
"permissions": []string{"bgp:modules:read"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTRejectedOnWrongIssuer(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": "https://other.example.com",
|
||||
"sub": "user-1",
|
||||
"apps": []string{"bgp"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"apps": []string{"cfdm", "portal"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "admin-1",
|
||||
"apps": []string{"bgp"},
|
||||
"is_admin": true,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthJWTMissingPermissionRejected(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
token := signTestJWT(t, jwt.MapClaims{
|
||||
"iss": testIssuer,
|
||||
"sub": "user-1",
|
||||
"apps": []string{"bgp"},
|
||||
"permissions": []string{"bgp:modules:read"},
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthConfigPublic(t *testing.T) {
|
||||
srv, _ := newJWTTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil)
|
||||
resp, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermissionSupersets(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
granted []string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true},
|
||||
{"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true},
|
||||
{"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true},
|
||||
{"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false},
|
||||
{"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false},
|
||||
{"empty-granted", nil, "bgp:modules:read", false},
|
||||
{"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := HasPermission(tc.granted, tc.want); got != tc.ok {
|
||||
t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Portal permission strings. Format: <app>:<section>:<level> (bgp:modules:read).
|
||||
// Superset order: admin ⊃ write ⊃ read for the same <app>:<section>.
|
||||
const (
|
||||
permLevelRead = "read"
|
||||
permLevelWrite = "write"
|
||||
permLevelAdmin = "admin"
|
||||
)
|
||||
|
||||
// permLevelRank returns 0 for unknown, 1 for read, 2 for write, 3 for admin.
|
||||
func permLevelRank(level string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case permLevelRead:
|
||||
return 1
|
||||
case permLevelWrite:
|
||||
return 2
|
||||
case permLevelAdmin:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// splitPerm splits a permission string into (app, section, level).
|
||||
func splitPerm(perm string) (app, section, level string, ok bool) {
|
||||
parts := strings.Split(strings.TrimSpace(perm), ":")
|
||||
if len(parts) != 3 {
|
||||
return "", "", "", false
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
|
||||
}
|
||||
|
||||
// HasPermission reports whether the granted list satisfies required, applying the
|
||||
// admin ⊃ write ⊃ read superset within the same app+section.
|
||||
func HasPermission(granted []string, required string) bool {
|
||||
rApp, rSection, rLevel, ok := splitPerm(required)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
needRank := permLevelRank(rLevel)
|
||||
if needRank == 0 {
|
||||
return false
|
||||
}
|
||||
for _, g := range granted {
|
||||
gApp, gSection, gLevel, ok := splitPerm(g)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(gApp, rApp) || !strings.EqualFold(gSection, rSection) {
|
||||
continue
|
||||
}
|
||||
if permLevelRank(gLevel) >= needRank {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// permAPIKeyRoleFor maps a permission level to the API-key role required.
|
||||
func permAPIKeyRoleFor(perm string) string {
|
||||
_, _, level, ok := splitPerm(perm)
|
||||
if !ok {
|
||||
return "operator"
|
||||
}
|
||||
switch strings.ToLower(level) {
|
||||
case permLevelRead:
|
||||
return "viewer"
|
||||
case permLevelWrite:
|
||||
return "editor"
|
||||
case permLevelAdmin:
|
||||
return "operator"
|
||||
default:
|
||||
return "operator"
|
||||
}
|
||||
}
|
||||
|
||||
// requirePerm enforces a permission for a portal JWT or falls back to the API-key role ladder.
|
||||
// node/firewall roles are always rejected (they use requireNode / requireFirewall).
|
||||
func (s *Server) requirePerm(w http.ResponseWriter, a Auth, perm string) bool {
|
||||
switch strings.ToLower(a.Role) {
|
||||
case "node":
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
||||
return false
|
||||
case "firewall":
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall role cannot access this resource")
|
||||
return false
|
||||
}
|
||||
if a.Kind == AuthKindJWT || len(a.Permissions) > 0 {
|
||||
if a.IsAdmin || HasPermission(a.Permissions, perm) {
|
||||
return true
|
||||
}
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "missing permission: "+perm)
|
||||
return false
|
||||
}
|
||||
need := permAPIKeyRoleFor(perm)
|
||||
if roleLevel(a.Role) < roleLevel(need) {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
+39
-41
@@ -35,6 +35,7 @@ func (s *Server) Handler() http.Handler {
|
||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
|
||||
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
|
||||
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
|
||||
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
|
||||
@@ -93,6 +94,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleAuthConfigPublic exposes portal-auth wiring so the UI can decide whether to redirect to the login portal.
|
||||
// Registered on the public mux (no auth middleware): safe to call without a bearer token.
|
||||
func (s *Server) handleAuthConfigPublic(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"required": s.authRequired,
|
||||
"portal_url": s.authPortalURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
@@ -188,7 +198,7 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||
@@ -204,23 +214,9 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
filtered := make([]*store.Module, 0)
|
||||
limit := parseListLimit(r)
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
if typeFilter == "" && enabledFilter == nil {
|
||||
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
|
||||
for _, mod := range page {
|
||||
filtered = append(filtered, mod)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(filtered))
|
||||
for _, mod := range filtered {
|
||||
items = append(items, moduleJSON(mod))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||
all := s.store.ListModules(a.TenantID)
|
||||
all = store.FilterOwned(all, func(m *store.Module) string { return m.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
for _, mod := range all {
|
||||
if typeFilter != "" && mod.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
@@ -245,7 +241,7 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
|
||||
@@ -268,10 +264,14 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
||||
if err == nil && !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, mod.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
@@ -289,10 +289,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
allPeers := s.store.ListPeers(a.TenantID)
|
||||
allPeers = store.FilterOwned(allPeers, func(p *store.BGPPeer) string { return p.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
@@ -392,7 +393,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
||||
@@ -424,7 +425,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "editor") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
@@ -460,7 +461,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "editor") {
|
||||
if !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -514,7 +515,7 @@ func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
@@ -587,7 +588,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
|
||||
@@ -604,7 +605,7 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
|
||||
@@ -643,7 +644,7 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
revID := r.PathValue("revision_id")
|
||||
@@ -669,7 +670,7 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
|
||||
@@ -686,7 +687,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "operator") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
revID := r.PathValue("revision_id")
|
||||
@@ -717,8 +718,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -764,8 +764,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
spkID := r.PathValue("id")
|
||||
@@ -815,8 +814,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
@@ -839,7 +837,7 @@ func (s *Server) handleBirdStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:monitoring:read") {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
@@ -854,7 +852,7 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
@@ -877,7 +875,7 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||
@@ -894,7 +892,7 @@ func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||
@@ -932,7 +930,7 @@ func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "editor") {
|
||||
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
|
||||
|
||||
@@ -21,13 +21,22 @@ func (s *Server) registerAPIKeyRoutes(m *http.ServeMux) {
|
||||
|
||||
func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
resp := map[string]any{
|
||||
"tenant_id": a.TenantID,
|
||||
"role": a.Role,
|
||||
})
|
||||
"kind": a.Kind,
|
||||
}
|
||||
if a.Kind == AuthKindJWT {
|
||||
resp["user_id"] = a.UserID
|
||||
resp["email"] = a.Email
|
||||
resp["permissions"] = a.Permissions
|
||||
resp["is_admin"] = a.IsAdmin
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func apiKeyJSON(k *store.APIKey) map[string]any {
|
||||
@@ -59,7 +68,7 @@ func apiKeyJSON(k *store.APIKey) map[string]any {
|
||||
|
||||
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListAPIKeys(a.TenantID)
|
||||
@@ -74,7 +83,7 @@ func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
k, err := s.store.GetAPIKey(a.TenantID, r.PathValue("id"))
|
||||
@@ -87,7 +96,7 @@ func (s *Server) handleGetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -127,7 +136,7 @@ func (s *Server) handlePostAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
@@ -183,7 +192,7 @@ func (s *Server) handlePatchAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeAPIKey(a.TenantID, r.PathValue("id")); err != nil {
|
||||
@@ -199,7 +208,7 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRotateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:access:admin") {
|
||||
return
|
||||
}
|
||||
rotated, err := s.store.RotateAPIKey(a.TenantID, r.PathValue("id"))
|
||||
|
||||
@@ -80,7 +80,7 @@ func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
|
||||
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -99,12 +99,16 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
mod, err := s.store.CreateModule(a.TenantID, &store.Module{
|
||||
newModule := &store.Module{
|
||||
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
|
||||
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
|
||||
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
|
||||
DohProfileIDs: body.DohProfileIDs, DohResolverPolicy: body.DohResolverPolicy,
|
||||
})
|
||||
}
|
||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
||||
newModule.CreatedByUserID = a.UserID
|
||||
}
|
||||
mod, err := s.store.CreateModule(a.TenantID, newModule)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -114,7 +118,7 @@ func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
rawBody, err := io.ReadAll(r.Body)
|
||||
@@ -158,7 +162,14 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
body.RefreshIntervalSec = &zero
|
||||
}
|
||||
}
|
||||
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
|
||||
moduleID := r.PathValue("module_id")
|
||||
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
mod, err := s.store.UpdateModule(a.TenantID, moduleID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -168,10 +179,17 @@ func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
|
||||
moduleID := r.PathValue("module_id")
|
||||
if existing, gerr := s.store.GetModule(a.TenantID, moduleID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.SoftDeleteModule(a.TenantID, moduleID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -215,7 +233,7 @@ func writePostgresStoreErr(w http.ResponseWriter, err error) bool {
|
||||
|
||||
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -248,7 +266,7 @@ func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||
|
||||
func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -327,7 +345,7 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.CDNSource
|
||||
@@ -357,7 +375,7 @@ func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.CDNSourcePatch
|
||||
@@ -387,7 +405,7 @@ func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -401,7 +419,7 @@ func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -439,7 +457,7 @@ func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
|
||||
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.ASEntry
|
||||
@@ -459,7 +477,7 @@ func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.ASEntryPatch
|
||||
@@ -479,7 +497,7 @@ func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -493,7 +511,7 @@ func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -516,7 +534,7 @@ func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
||||
|
||||
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.DomainEntry
|
||||
@@ -536,7 +554,7 @@ func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.DomainEntryPatch
|
||||
@@ -556,7 +574,7 @@ func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -570,7 +588,7 @@ func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
|
||||
@@ -593,7 +611,7 @@ func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
||||
|
||||
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.IPRangeEntry
|
||||
@@ -613,7 +631,7 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
var body store.IPRangePatch
|
||||
@@ -633,7 +651,7 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
mid := r.PathValue("module_id")
|
||||
@@ -647,7 +665,7 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:read") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
@@ -730,7 +748,7 @@ func (s *Server) handleExportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:modules:write") {
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
@@ -774,7 +792,7 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListDohProfiles(a.TenantID)
|
||||
@@ -806,7 +824,7 @@ func dohJSON(x *store.DohProfile) map[string]any {
|
||||
|
||||
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
|
||||
@@ -819,7 +837,7 @@ func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.DohProfile
|
||||
@@ -837,7 +855,7 @@ func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.DohProfilePatch
|
||||
@@ -855,7 +873,7 @@ func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
|
||||
@@ -867,7 +885,7 @@ func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListCommunities(a.TenantID)
|
||||
@@ -892,7 +910,7 @@ func commJSON(x *store.Community) map[string]any {
|
||||
|
||||
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
|
||||
@@ -905,7 +923,7 @@ func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.Community
|
||||
@@ -923,7 +941,7 @@ func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
var body store.CommunityPatch
|
||||
@@ -941,7 +959,7 @@ func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:directories:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
|
||||
@@ -953,7 +971,7 @@ func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.BGPPeer
|
||||
@@ -962,6 +980,9 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
body.TenantID = a.TenantID
|
||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
||||
body.CreatedByUserID = a.UserID
|
||||
}
|
||||
x, err := s.store.CreatePeer(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
@@ -973,7 +994,7 @@ func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
|
||||
@@ -981,12 +1002,16 @@ func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, x.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, peerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.PeerPatch
|
||||
@@ -994,7 +1019,14 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
|
||||
peerID := r.PathValue("id")
|
||||
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
x, err := s.store.UpdatePeer(a.TenantID, peerID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -1005,10 +1037,17 @@ func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
|
||||
peerID := r.PathValue("id")
|
||||
if existing, gerr := s.store.GetPeer(a.TenantID, peerID); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "peer not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.DeletePeer(a.TenantID, peerID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -1018,7 +1057,7 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.Speaker
|
||||
@@ -1044,7 +1083,7 @@ func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
|
||||
@@ -1057,7 +1096,7 @@ func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
var body store.SpeakerPatch
|
||||
@@ -1075,7 +1114,7 @@ func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:write") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteSpeaker(a.TenantID, r.PathValue("speaker_id")); err != nil {
|
||||
@@ -1087,7 +1126,7 @@ func (s *Server) handleDeleteSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
@@ -1111,7 +1150,7 @@ func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
|
||||
return
|
||||
}
|
||||
m, err := s.store.ListGlobalSettings(a.TenantID)
|
||||
@@ -1124,7 +1163,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") {
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
|
||||
@@ -43,7 +43,7 @@ func (s *Server) registerFirewallRoutes(m *http.ServeMux) {
|
||||
|
||||
func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
seed := strings.TrimSpace(s.bundleSeedHex)
|
||||
@@ -187,7 +187,7 @@ func readFirewallScript(name string) ([]byte, error) {
|
||||
|
||||
func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListFirewallClients(a.TenantID)
|
||||
@@ -195,12 +195,13 @@ func (s *Server) handleListFirewallClients(w http.ResponseWriter, r *http.Reques
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
items = store.FilterOwned(items, func(c *store.FirewallClient) string { return c.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
@@ -209,15 +210,25 @@ func (s *Server) handleGetFirewallClient(w http.ResponseWriter, r *http.Request)
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, client.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, client)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
var patch store.FirewallClientPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
||||
@@ -233,10 +244,16 @@ func (s *Server) handlePatchFirewallClient(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
client, err := s.store.ApproveFirewallClient(a.TenantID, id, a.APIKeyID)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
@@ -249,10 +266,16 @@ func (s *Server) handleApproveFirewallClient(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.RevokeFirewallClient(a.TenantID, id); err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
@@ -264,10 +287,16 @@ func (s *Server) handleRevokeFirewallClient(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallClient(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteFirewallClient(a.TenantID, id); err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "client not found")
|
||||
return
|
||||
@@ -279,7 +308,7 @@ func (s *Server) handleDeleteFirewallClient(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
scope := strings.TrimSpace(r.URL.Query().Get("scope"))
|
||||
@@ -297,12 +326,13 @@ func (s *Server) handleListFirewallRules(w http.ResponseWriter, r *http.Request)
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
items = store.FilterOwned(items, func(rule *store.FirewallRule) string { return rule.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -326,12 +356,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
|
||||
cid := strings.TrimSpace(*body.ClientID)
|
||||
clientID = &cid
|
||||
}
|
||||
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, &store.FirewallRuleCreate{
|
||||
fwRule := &store.FirewallRuleCreate{
|
||||
Priority: body.Priority,
|
||||
Action: body.Action,
|
||||
CommunityID: body.CommunityID,
|
||||
Comment: body.Comment,
|
||||
})
|
||||
}
|
||||
if a.Kind == AuthKindJWT && strings.TrimSpace(a.UserID) != "" {
|
||||
fwRule.CreatedByUserID = a.UserID
|
||||
}
|
||||
rule, err := s.store.CreateFirewallRule(a.TenantID, clientID, fwRule)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid rule")
|
||||
return
|
||||
@@ -342,10 +376,16 @@ func (s *Server) handleCreateFirewallRule(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
var patch store.FirewallRulePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
||||
@@ -362,10 +402,16 @@ func (s *Server) handlePatchFirewallRule(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if existing, gerr := s.store.GetFirewallRule(a.TenantID, id); gerr == nil {
|
||||
if !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, existing.CreatedByUserID) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteFirewallRule(a.TenantID, id); err != nil {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "rule not found")
|
||||
return
|
||||
@@ -376,7 +422,7 @@ func (s *Server) handleDeleteFirewallRule(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleReorderFirewallRules(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:write") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -494,7 +540,7 @@ func (s *Server) handleFirewallHeartbeat(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleFirewallClientPreview(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:firewall:read") {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
|
||||
@@ -16,7 +16,7 @@ func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
if !s.requirePerm(w, a, "bgp:lookup:read") {
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
|
||||
@@ -62,7 +62,7 @@ func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any {
|
||||
|
||||
func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
@@ -81,7 +81,7 @@ func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Re
|
||||
|
||||
func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
p, err := s.store.GetMaintenancePolicy(r.PathValue("id"))
|
||||
@@ -163,7 +163,7 @@ func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Re
|
||||
|
||||
func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
if s.maintStats == nil {
|
||||
@@ -185,7 +185,7 @@ func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
|
||||
@@ -27,12 +27,10 @@ func (s *Server) registerPostgresMaintenanceRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /postgres/maintenance/logs", s.handlePostgresMaintenanceLogs)
|
||||
}
|
||||
|
||||
// requireOperatorStrict is a compatibility shim mapping the legacy "operator"
|
||||
// API-key role to the tenant-settings admin permission for JWT/API-key clients.
|
||||
func (s *Server) requireOperatorStrict(w http.ResponseWriter, a Auth) bool {
|
||||
if strings.ToLower(a.Role) != "operator" {
|
||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return s.requirePerm(w, a, "bgp:tenant_settings:admin")
|
||||
}
|
||||
|
||||
func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
|
||||
@@ -201,7 +199,7 @@ func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
|
||||
@@ -35,7 +35,7 @@ func parseLimitQuery(r *http.Request, def, max int) int {
|
||||
|
||||
func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -50,7 +50,7 @@ func (s *Server) handlePostgresOverview(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -65,7 +65,7 @@ func (s *Server) handlePostgresQueries(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -80,7 +80,7 @@ func (s *Server) handlePostgresLocks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
@@ -95,7 +95,7 @@ func (s *Server) handlePostgresTables(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
@@ -110,7 +110,7 @@ func (s *Server) handlePostgresRecommendations(w http.ResponseWriter, r *http.Re
|
||||
|
||||
func (s *Server) handleMonitoringCorrelation(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) {
|
||||
return
|
||||
}
|
||||
window := 60
|
||||
|
||||
@@ -52,7 +52,7 @@ func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID s
|
||||
|
||||
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:operations:read") {
|
||||
return
|
||||
}
|
||||
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
|
||||
@@ -78,7 +78,7 @@ func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||
return
|
||||
}
|
||||
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
|
||||
|
||||
@@ -70,7 +70,7 @@ func runtimeLogCleanupAuditJSON(row *store.RuntimeLogCleanupAudit) map[string]an
|
||||
|
||||
func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
items, err := s.runtimeLogs.ListFiles()
|
||||
@@ -87,7 +87,7 @@ func (s *Server) handleListRuntimeLogFiles(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
filename := r.PathValue("filename")
|
||||
@@ -111,7 +111,7 @@ func (s *Server) handleGetRuntimeLogTail(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleDeleteRuntimeLogFile(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
filename := r.PathValue("filename")
|
||||
@@ -157,7 +157,7 @@ func (s *Server) runtimeLogAutoPolicy(w http.ResponseWriter, r *http.Request, te
|
||||
|
||||
func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||
@@ -199,7 +199,7 @@ func (s *Server) handleRuntimeLogAutoEstimate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") || !s.requireRuntimeLogs(w) {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:tenant_settings:admin") || !s.requireRuntimeLogs(w) {
|
||||
return
|
||||
}
|
||||
policy, ok := s.runtimeLogAutoPolicy(w, r, a.TenantID)
|
||||
@@ -221,7 +221,7 @@ func (s *Server) handleRuntimeLogAutoRun(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (s *Server) handleListRuntimeLogCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") {
|
||||
return
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
|
||||
@@ -36,6 +36,13 @@ type Server struct {
|
||||
runtimeLogs *runtimelogs.Service
|
||||
runtimeLogsPolicyTenant string
|
||||
mux *http.ServeMux
|
||||
|
||||
// Portal / dual-auth (JWT) configuration.
|
||||
jwtSecret string
|
||||
authIssuer string
|
||||
authPortalURL string
|
||||
portalTenantID string
|
||||
authRequired bool
|
||||
}
|
||||
|
||||
// Options configures the API server.
|
||||
@@ -49,6 +56,13 @@ type Options struct {
|
||||
CORSAllowedOrigins string
|
||||
// RuntimeLogsPolicyTenant overrides tenant for auto-cleanup scheduler settings (optional).
|
||||
RuntimeLogsPolicyTenant string
|
||||
|
||||
// Portal / dual-auth (JWT) — leave empty to disable JWT path.
|
||||
JWTSecret string // AUTH_JWT_SECRET / EVOBGP_AUTH_JWT_SECRET (HS256 shared secret)
|
||||
AuthIssuer string // AUTH_ISSUER (expected iss claim; default https://auth.shnt.top)
|
||||
AuthPortalURL string // AUTH_PORTAL_URL (returned by /v1/auth/config for the UI)
|
||||
PortalTenantID string // EVOBGP_PORTAL_TENANT_ID (single tenant scope for JWT users)
|
||||
AuthRequired bool // AUTH_REQUIRED / EVOBGP_AUTH_REQUIRED (surfaced via /v1/auth/config)
|
||||
}
|
||||
|
||||
// New constructs Server and wiring for async jobs.
|
||||
@@ -104,6 +118,14 @@ func New(opts Options) (*Server, error) {
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
runtimeLogs: runtimelogs.NewService(runtimelogs.ConfigFromEnv()),
|
||||
runtimeLogsPolicyTenant: strings.TrimSpace(opts.RuntimeLogsPolicyTenant),
|
||||
jwtSecret: strings.TrimSpace(opts.JWTSecret),
|
||||
authIssuer: strings.TrimSpace(opts.AuthIssuer),
|
||||
authPortalURL: strings.TrimSpace(opts.AuthPortalURL),
|
||||
portalTenantID: strings.TrimSpace(opts.PortalTenantID),
|
||||
authRequired: opts.AuthRequired,
|
||||
}
|
||||
if s.authIssuer == "" {
|
||||
s.authIssuer = "https://auth.shnt.top"
|
||||
}
|
||||
s.mux = http.NewServeMux()
|
||||
s.registerRoutes()
|
||||
|
||||
@@ -71,7 +71,7 @@ func speakerJSONFromStore(st store.Backend, sp *store.Speaker) map[string]any {
|
||||
|
||||
func (s *Server) handleBundleSigningPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
if !ok || !s.requirePerm(w, a, "bgp:network:read") {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
|
||||
@@ -119,7 +119,7 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -133,7 +133,8 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
var doh, dc, cron *string
|
||||
var refresh *int32
|
||||
var last *time.Time
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
|
||||
var createdBy *string
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
|
||||
continue
|
||||
}
|
||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||
@@ -153,6 +154,9 @@ func (p *Postgres) ListModules(tenantID string) []*store.Module {
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
if createdBy != nil {
|
||||
m.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
out = append(out, &m)
|
||||
moduleByID[m.ID] = &m
|
||||
}
|
||||
@@ -175,7 +179,7 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
||||
FROM module WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY priority, name
|
||||
LIMIT $2 OFFSET $3`, tenantID, limit+1, off)
|
||||
@@ -191,7 +195,8 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
||||
var doh, dc, cron *string
|
||||
var refresh *int32
|
||||
var last *time.Time
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last); err != nil {
|
||||
var createdBy *string
|
||||
if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy); err != nil {
|
||||
continue
|
||||
}
|
||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||
@@ -211,6 +216,9 @@ func (p *Postgres) ListModulesPage(tenantID, cursor string, limit int) ([]*store
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
if createdBy != nil {
|
||||
m.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
out = append(out, &m)
|
||||
moduleByID[m.ID] = &m
|
||||
}
|
||||
@@ -238,11 +246,12 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
||||
var doh, dc, cron *string
|
||||
var refresh *int32
|
||||
var last *time.Time
|
||||
var createdBy *string
|
||||
err := p.pool.QueryRow(ctx, `
|
||||
SELECT id, type, name, enabled, priority, doh_profile_id::text, doh_resolver_policy,
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at
|
||||
refresh_interval_sec, cron_expr, default_community_id::text, last_refreshed_at, created_by_user_id
|
||||
FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan(
|
||||
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last)
|
||||
&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &m.DohResolverPolicy, &refresh, &cron, &dc, &last, &createdBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
@@ -265,6 +274,9 @@ func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) {
|
||||
t := last.UTC()
|
||||
m.LastRefreshedAt = &t
|
||||
}
|
||||
if createdBy != nil {
|
||||
m.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
m.DohResolverPolicy = store.NormalizeDohResolverPolicy(m.DohResolverPolicy)
|
||||
if err := p.fillModuleDohFields(ctx, &m); err != nil {
|
||||
return nil, err
|
||||
@@ -299,10 +311,14 @@ func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Modul
|
||||
lastArg = in.LastRefreshedAt.UTC()
|
||||
}
|
||||
policy := store.NormalizeDohResolverPolicy(in.DohResolverPolicy)
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg)
|
||||
INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, doh_resolver_policy, refresh_interval_sec, cron_expr, default_community_id, last_refreshed_at, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
||||
id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, policy, ri, cronArg, dc, lastArg, createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -402,7 +418,8 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
|
||||
created_by_user_id
|
||||
FROM bgp_peer WHERE tenant_id=$1 ORDER BY neighbor`, tenantID)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -412,10 +429,14 @@ func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer {
|
||||
for rows.Next() {
|
||||
var peer store.BGPPeer
|
||||
var sp *string
|
||||
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON); err != nil {
|
||||
var createdBy *string
|
||||
if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy); err != nil {
|
||||
continue
|
||||
}
|
||||
peer.SpeakerID = sp
|
||||
if createdBy != nil {
|
||||
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
out = append(out, &peer)
|
||||
}
|
||||
return out
|
||||
@@ -425,11 +446,13 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
|
||||
ctx := context.Background()
|
||||
var peer store.BGPPeer
|
||||
var sp *string
|
||||
var createdBy *string
|
||||
err := p.pool.QueryRow(ctx, `
|
||||
SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled,
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}')
|
||||
COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}'),
|
||||
created_by_user_id
|
||||
FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan(
|
||||
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON)
|
||||
&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON, &createdBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
@@ -437,6 +460,9 @@ func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) {
|
||||
return nil, err
|
||||
}
|
||||
peer.SpeakerID = sp
|
||||
if createdBy != nil {
|
||||
peer.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
return &peer, nil
|
||||
}
|
||||
|
||||
@@ -461,10 +487,14 @@ func (p *Postgres) CreatePeer(tenantID string, in *store.BGPPeer) (*store.BGPPee
|
||||
sp = strings.TrimSpace(*in.SpeakerID)
|
||||
}
|
||||
enabled := store.EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState)
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
|
||||
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb)`,
|
||||
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb))
|
||||
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
||||
id, tenantID, sp, neighbor, in.RemoteASN, enabled, pol, string(mb), createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const firewallClientSelectCols = `
|
||||
COALESCE(last_apply_prefix_count, 0), COALESCE(last_apply_ip_count, 0),
|
||||
COALESCE(last_apply_packets_dropped, 0), COALESCE(last_apply_packets_accepted, 0),
|
||||
COALESCE(last_apply_source, ''),
|
||||
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at`
|
||||
COALESCE(client_version, ''), created_at, approved_at, approved_by_api_key_id, revoked_at, created_by_user_id`
|
||||
|
||||
func (p *Postgres) ListFirewallClients(tenantID string) ([]*store.FirewallClient, error) {
|
||||
ctx := context.Background()
|
||||
@@ -63,11 +63,15 @@ func (p *Postgres) CreateFirewallClient(tenantID string, in *store.FirewallClien
|
||||
}
|
||||
id := uuid.NewString()
|
||||
ctx := context.Background()
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
INSERT INTO firewall_client (id, tenant_id, name, hostname, token_prefix, token_hash, client_version, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
id, tenantID, strings.TrimSpace(in.Name), strings.TrimSpace(in.Hostname),
|
||||
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion))
|
||||
in.TokenPrefix, in.TokenHash, strings.TrimSpace(in.ClientVersion), createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -232,11 +236,11 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
|
||||
var err error
|
||||
if clientID == nil {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE tenant_id=$1 AND client_id IS NULL ORDER BY priority`, tenantID)
|
||||
} else {
|
||||
rows, err = p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE tenant_id=$1 AND client_id=$2 ORDER BY priority`, tenantID, *clientID)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -249,7 +253,7 @@ func (p *Postgres) ListFirewallRules(tenantID string, clientID *string) ([]*stor
|
||||
func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule
|
||||
WHERE tenant_id=$1 AND (client_id IS NULL OR client_id=$2)
|
||||
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, priority`, tenantID, clientID)
|
||||
@@ -263,7 +267,7 @@ func (p *Postgres) ListAllFirewallRulesForClient(tenantID, clientID string) ([]*
|
||||
func (p *Postgres) ListAllFirewallRulesForReplication(tenantID string) ([]*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE tenant_id=$1
|
||||
ORDER BY CASE WHEN client_id IS NULL THEN 1 ELSE 0 END, client_id, priority`, tenantID)
|
||||
if err != nil {
|
||||
@@ -294,10 +298,14 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
|
||||
}
|
||||
id := uuid.NewString()
|
||||
ctx := context.Background()
|
||||
var createdBy any
|
||||
if v := strings.TrimSpace(in.CreatedByUserID); v != "" {
|
||||
createdBy = v
|
||||
}
|
||||
_, err := p.pool.Exec(ctx, `
|
||||
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment))
|
||||
INSERT INTO firewall_rule (id, tenant_id, client_id, priority, action, community_id, comment, created_by_user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
id, tenantID, clientID, priority, strings.ToLower(strings.TrimSpace(in.Action)), in.CommunityID, strings.TrimSpace(in.Comment), createdBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -307,7 +315,7 @@ func (p *Postgres) CreateFirewallRule(tenantID string, clientID *string, in *sto
|
||||
func (p *Postgres) GetFirewallRule(tenantID, id string) (*store.FirewallRule, error) {
|
||||
ctx := context.Background()
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at
|
||||
SELECT id, client_id, priority, action, community_id, comment, created_at, updated_at, created_by_user_id
|
||||
FROM firewall_rule WHERE id=$1 AND tenant_id=$2`, id, tenantID)
|
||||
r, err := scanFirewallRuleRow(row.Scan, tenantID)
|
||||
if err != nil {
|
||||
@@ -423,19 +431,22 @@ func scanFirewallRules(rows pgx.Rows, tenantID string) ([]*store.FirewallRule, e
|
||||
func scanFirewallRuleRow(scan scanFn, tenantID string) (*store.FirewallRule, error) {
|
||||
var r store.FirewallRule
|
||||
r.TenantID = tenantID
|
||||
var clientID, communityID *string
|
||||
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
var clientID, communityID, createdBy *string
|
||||
if err := scan(&r.ID, &clientID, &r.Priority, &r.Action, &communityID, &r.Comment, &r.CreatedAt, &r.UpdatedAt, &createdBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.ClientID = clientID
|
||||
r.CommunityID = communityID
|
||||
if createdBy != nil {
|
||||
r.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient, error) {
|
||||
var c store.FirewallClient
|
||||
c.TenantID = tenantID
|
||||
var approvedBy *string
|
||||
var approvedBy, createdBy *string
|
||||
var lastSeen, lastApply, approved, revoked *time.Time
|
||||
var prefixCount, ipCount *int
|
||||
var packetsDropped, packetsAccepted *int64
|
||||
@@ -444,16 +455,16 @@ func scanFirewallClientRow(scan scanFn, tenantID string) (*store.FirewallClient,
|
||||
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
|
||||
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
|
||||
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
}
|
||||
|
||||
func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
|
||||
var c store.FirewallClient
|
||||
var approvedBy *string
|
||||
var approvedBy, createdBy *string
|
||||
var lastSeen, lastApply, approved, revoked *time.Time
|
||||
var prefixCount, ipCount *int
|
||||
var packetsDropped, packetsAccepted *int64
|
||||
@@ -462,14 +473,14 @@ func scanFirewallClientLookupRow(scan scanFn) (*store.FirewallClient, error) {
|
||||
&lastSeen, &c.LastSeenAtSource, &c.LastSeenIP,
|
||||
&lastApply, &c.LastApplyStatus, &c.LastApplyError,
|
||||
&prefixCount, &ipCount, &packetsDropped, &packetsAccepted, &c.LastApplySource,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked,
|
||||
&c.ClientVersion, &c.CreatedAt, &approved, &approvedBy, &revoked, &createdBy,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
return finishFirewallClientScan(&c, lastSeen, lastApply, approved, revoked, approvedBy, createdBy, prefixCount, ipCount, packetsDropped, packetsAccepted), nil
|
||||
}
|
||||
|
||||
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
|
||||
func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, approved, revoked *time.Time, approvedBy, createdBy *string, prefixCount, ipCount *int, packetsDropped, packetsAccepted *int64) *store.FirewallClient {
|
||||
c.LastSeenAt = lastSeen
|
||||
c.LastApplyAt = lastApply
|
||||
c.ApprovedAt = approved
|
||||
@@ -477,6 +488,9 @@ func finishFirewallClientScan(c *store.FirewallClient, lastSeen, lastApply, appr
|
||||
if approvedBy != nil {
|
||||
c.ApprovedByAPIKeyID = *approvedBy
|
||||
}
|
||||
if createdBy != nil {
|
||||
c.CreatedByUserID = strings.TrimSpace(*createdBy)
|
||||
}
|
||||
if prefixCount != nil {
|
||||
c.LastApplyPrefixCount = *prefixCount
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ type Backend interface {
|
||||
ListAllFirewallRulesForClient(tenantID, clientID string) ([]*FirewallRule, error)
|
||||
ListAllFirewallRulesForReplication(tenantID string) ([]*FirewallRule, error)
|
||||
CreateFirewallRule(tenantID string, clientID *string, in *FirewallRuleCreate) (*FirewallRule, error)
|
||||
GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error)
|
||||
UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error)
|
||||
DeleteFirewallRule(tenantID, ruleID string) error
|
||||
ReorderFirewallRules(tenantID string, clientID *string, orderedIDs []string) error
|
||||
|
||||
@@ -29,15 +29,17 @@ type FirewallClient struct {
|
||||
ApprovedAt *time.Time `json:"approved_at,omitempty"`
|
||||
ApprovedByAPIKeyID string `json:"approved_by_api_key_id,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallClientCreate is input for enroll (token hash supplied by caller).
|
||||
type FirewallClientCreate struct {
|
||||
Name string
|
||||
Hostname string
|
||||
TokenPrefix string
|
||||
TokenHash []byte
|
||||
ClientVersion string
|
||||
Name string
|
||||
Hostname string
|
||||
TokenPrefix string
|
||||
TokenHash []byte
|
||||
ClientVersion string
|
||||
CreatedByUserID string
|
||||
}
|
||||
|
||||
// FirewallClientPatch is a partial update for operator edits.
|
||||
@@ -62,23 +64,25 @@ type FirewallClientReplicationRow struct {
|
||||
|
||||
// FirewallRule is one block/accept policy rule.
|
||||
type FirewallRule struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
}
|
||||
|
||||
// FirewallRuleCreate is input for creating a rule.
|
||||
type FirewallRuleCreate struct {
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Action string `json:"action"`
|
||||
CommunityID *string `json:"community_id,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
CreatedByUserID string `json:"-"`
|
||||
}
|
||||
|
||||
// FirewallRulePatch is a partial rule update.
|
||||
|
||||
@@ -95,6 +95,7 @@ type Module struct {
|
||||
DohResolverPolicy string
|
||||
LastRefreshedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
CreatedByUserID string // portal JWT sub; empty = system / API key
|
||||
}
|
||||
|
||||
type Revision struct {
|
||||
@@ -112,15 +113,16 @@ type Revision struct {
|
||||
|
||||
// BGPPeer maps to bgp_peer (+ display fields in meta).
|
||||
type BGPPeer struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
SpeakerID *string `json:"bgp_speaker_id"`
|
||||
Name string `json:"name"`
|
||||
Neighbor string `json:"neighbor"`
|
||||
RemoteASN int64 `json:"remote_asn"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SessionState string `json:"session_state"`
|
||||
PoliciesJSON string `json:"policies_json"`
|
||||
ID string `json:"id,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
SpeakerID *string `json:"bgp_speaker_id"`
|
||||
Name string `json:"name"`
|
||||
Neighbor string `json:"neighbor"`
|
||||
RemoteASN int64 `json:"remote_asn"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SessionState string `json:"session_state"`
|
||||
PoliciesJSON string `json:"policies_json"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
}
|
||||
|
||||
type Speaker struct {
|
||||
|
||||
@@ -32,6 +32,7 @@ func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
|
||||
DohProfileIDs: append([]string(nil), in.DohProfileIDs...),
|
||||
DohResolverPolicy: in.DohResolverPolicy,
|
||||
LastRefreshedAt: in.LastRefreshedAt,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
}
|
||||
NormalizeModuleDoh(mod)
|
||||
m.modules[id] = mod
|
||||
@@ -680,6 +681,7 @@ func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) {
|
||||
Neighbor: neighbor, RemoteASN: in.RemoteASN,
|
||||
Enabled: EffectivePeerEnabledOnCreate(in.Enabled, in.SessionState),
|
||||
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
}
|
||||
m.peers[id] = p
|
||||
return p, nil
|
||||
|
||||
@@ -49,14 +49,15 @@ func (m *Memory) CreateFirewallClient(tenantID string, in *FirewallClientCreate)
|
||||
id := uuid.NewString()
|
||||
rec := &firewallClientRec{
|
||||
FirewallClient: FirewallClient{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Hostname: strings.TrimSpace(in.Hostname),
|
||||
TokenPrefix: in.TokenPrefix,
|
||||
Status: "pending",
|
||||
ClientVersion: strings.TrimSpace(in.ClientVersion),
|
||||
CreatedAt: now,
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Hostname: strings.TrimSpace(in.Hostname),
|
||||
TokenPrefix: in.TokenPrefix,
|
||||
Status: "pending",
|
||||
ClientVersion: strings.TrimSpace(in.ClientVersion),
|
||||
CreatedAt: now,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
},
|
||||
TokenHash: append([]byte(nil), in.TokenHash...),
|
||||
}
|
||||
@@ -311,20 +312,31 @@ func (m *Memory) CreateFirewallRule(tenantID string, clientID *string, in *Firew
|
||||
now := time.Now().UTC()
|
||||
id := uuid.NewString()
|
||||
rule := &FirewallRule{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
ClientID: clientID,
|
||||
Priority: priority,
|
||||
Action: strings.ToLower(strings.TrimSpace(in.Action)),
|
||||
CommunityID: in.CommunityID,
|
||||
Comment: strings.TrimSpace(in.Comment),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
ClientID: clientID,
|
||||
Priority: priority,
|
||||
Action: strings.ToLower(strings.TrimSpace(in.Action)),
|
||||
CommunityID: in.CommunityID,
|
||||
Comment: strings.TrimSpace(in.Comment),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatedByUserID: strings.TrimSpace(in.CreatedByUserID),
|
||||
}
|
||||
m.firewallRules[id] = rule
|
||||
return firewallRuleCopy(rule), nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetFirewallRule(tenantID, ruleID string) (*FirewallRule, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rule, ok := m.firewallRules[ruleID]
|
||||
if !ok || rule.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return firewallRuleCopy(rule), nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateFirewallRule(tenantID, ruleID string, patch *FirewallRulePatch) (*FirewallRule, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
// Ownership helpers for portal JWT resource scoping.
|
||||
|
||||
// SeesAllOwned is true for API keys and portal admins (no per-user filter).
|
||||
func SeesAllOwned(kind string, isAdmin bool) bool {
|
||||
if kind != "jwt" {
|
||||
return true
|
||||
}
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
// CanAccessOwned reports whether the actor may see/edit a resource with createdBy.
|
||||
// Empty createdBy (legacy/API-key-created) is visible only when SeesAllOwned.
|
||||
func CanAccessOwned(kind string, isAdmin bool, userID, createdBy string) bool {
|
||||
if SeesAllOwned(kind, isAdmin) {
|
||||
return true
|
||||
}
|
||||
if createdBy == "" {
|
||||
return false
|
||||
}
|
||||
return createdBy == userID
|
||||
}
|
||||
|
||||
// FilterOwnedStrings keeps items whose owner matches the actor.
|
||||
func FilterOwned[T any](items []T, owner func(T) string, kind string, isAdmin bool, userID string) []T {
|
||||
if SeesAllOwned(kind, isAdmin) {
|
||||
return items
|
||||
}
|
||||
out := make([]T, 0, len(items))
|
||||
for _, it := range items {
|
||||
if CanAccessOwned(kind, isAdmin, userID, owner(it)) {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSeesAllOwned(t *testing.T) {
|
||||
if !SeesAllOwned("apikey", false) {
|
||||
t.Fatal("api keys must see all rows")
|
||||
}
|
||||
if !SeesAllOwned("jwt", true) {
|
||||
t.Fatal("admin jwt must see all rows")
|
||||
}
|
||||
if SeesAllOwned("jwt", false) {
|
||||
t.Fatal("non-admin jwt must not see all rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanAccessOwned(t *testing.T) {
|
||||
if !CanAccessOwned("apikey", false, "", "someone") {
|
||||
t.Fatal("api key must access any owner")
|
||||
}
|
||||
if !CanAccessOwned("jwt", true, "admin", "user-1") {
|
||||
t.Fatal("admin jwt must access any owner")
|
||||
}
|
||||
if !CanAccessOwned("jwt", false, "user-1", "user-1") {
|
||||
t.Fatal("owner must access their resource")
|
||||
}
|
||||
if CanAccessOwned("jwt", false, "user-1", "user-2") {
|
||||
t.Fatal("non-owner must not access foreign resource")
|
||||
}
|
||||
if CanAccessOwned("jwt", false, "user-1", "") {
|
||||
t.Fatal("non-admin jwt must not see legacy rows without owner")
|
||||
}
|
||||
}
|
||||
|
||||
type ownRow struct {
|
||||
id string
|
||||
owner string
|
||||
}
|
||||
|
||||
func TestFilterOwned(t *testing.T) {
|
||||
rows := []ownRow{
|
||||
{"a", "user-1"},
|
||||
{"b", "user-2"},
|
||||
{"c", ""},
|
||||
}
|
||||
get := func(r ownRow) string { return r.owner }
|
||||
|
||||
got := FilterOwned(rows, get, "apikey", false, "")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("apikey filter: got=%d want 3", len(got))
|
||||
}
|
||||
|
||||
got = FilterOwned(rows, get, "jwt", true, "any")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("admin jwt filter: got=%d want 3", len(got))
|
||||
}
|
||||
|
||||
got = FilterOwned(rows, get, "jwt", false, "user-1")
|
||||
if len(got) != 1 || got[0].id != "a" {
|
||||
t.Fatalf("user-1 filter: got=%+v want [a]", got)
|
||||
}
|
||||
|
||||
got = FilterOwned(rows, get, "jwt", false, "user-3")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("unknown user filter: got=%+v want []", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user