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:
@@ -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