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

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:
Denozordec
2026-07-18 23:23:52 +07:00
co-authored by Cursor
parent 2820cff988
commit 4d83b8d673
48 changed files with 1839 additions and 254 deletions
+1
View File
@@ -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
+22 -18
View File
@@ -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.
+11 -9
View File
@@ -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 {
+2
View File
@@ -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
+29 -17
View File
@@ -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
+37
View File
@@ -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
}
+67
View File
@@ -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)
}
}