authz_client / test (push) Skipped
authz_client / vulnerabilities (push) Skipped
pre-commit / pre-commit (push) Skipped
authz_client / vulnerabilities (pull_request) Failing after 55s
authz_client / test (pull_request) Successful in 1m4s
pre-commit / pre-commit (pull_request) Successful in 2m16s
Setup() registers one transient consumer per routing key, and each mints its own queue drained by its own goroutine, so User.Added and Privilege.Added for the same company can be processed in either order. Process(*UserAdded) replaced the company entry with empty privileges, so a Privilege.Added handled first lost its privilege until the next Fetch(), which only runs at service start. Create the entry only when it is missing instead, matching authz-service's own aggregate and read view, which both leave an existing user entry alone. This also makes a User.Added redelivery harmless. Seen as authz-service #826 acceptance-test failures: a new company's Admin grant vanished, so company-service's CreateCompany callback waited out its 30s timeout and the company page stayed empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMvdB7bcwn1CrQKM4dCshM
214 lines
5.8 KiB
Go
214 lines
5.8 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"sync"
|
|
|
|
goamqp "codeberg.org/messaging/go-messaging-amqp"
|
|
spec "codeberg.org/messaging/messaging"
|
|
)
|
|
|
|
// CompanyPrivileges contains the privileges for a combination of email address and company id
|
|
type CompanyPrivileges struct {
|
|
Admin bool `json:"admin"`
|
|
Company bool `json:"company"`
|
|
Consumer bool `json:"consumer"`
|
|
Time bool `json:"time"`
|
|
Invoicing bool `json:"invoicing"`
|
|
Accounting bool `json:"accounting"`
|
|
Supplier bool `json:"supplier"`
|
|
Salary bool `json:"salary"`
|
|
}
|
|
|
|
// PrivilegeHandler processes PrivilegeAdded-events and fetches the initial set of privileges from an authz-service
|
|
type PrivilegeHandler struct {
|
|
*sync.RWMutex
|
|
client *http.Client
|
|
baseURL string
|
|
apiKey string
|
|
privileges map[string]map[string]*CompanyPrivileges
|
|
}
|
|
|
|
// OptsFunc is used to configure the PrivilegeHandler
|
|
type OptsFunc func(handler *PrivilegeHandler)
|
|
|
|
// WithBaseURL sets the base URL to the authz-service
|
|
func WithBaseURL(url string) OptsFunc {
|
|
return func(handler *PrivilegeHandler) {
|
|
handler.baseURL = url
|
|
}
|
|
}
|
|
|
|
// WithAPIKey sets an API key used as a Bearer token when fetching privileges
|
|
func WithAPIKey(key string) OptsFunc {
|
|
return func(handler *PrivilegeHandler) {
|
|
handler.apiKey = key
|
|
}
|
|
}
|
|
|
|
// New creates a new PrivilegeHandler. Pass OptsFuncs to configure.
|
|
func New(opts ...OptsFunc) *PrivilegeHandler {
|
|
handler := &PrivilegeHandler{
|
|
RWMutex: &sync.RWMutex{},
|
|
client: &http.Client{},
|
|
baseURL: "http://authz-service",
|
|
privileges: map[string]map[string]*CompanyPrivileges{},
|
|
}
|
|
for _, opt := range opts {
|
|
opt(handler)
|
|
}
|
|
return handler
|
|
}
|
|
|
|
// Fetch the initial set of privileges from an authz-service
|
|
func (h *PrivilegeHandler) Fetch() error {
|
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/authz", h.baseURL), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if h.apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+h.apiKey)
|
|
}
|
|
|
|
resp, err := h.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
buff, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
h.Lock()
|
|
defer h.Unlock()
|
|
err = json.Unmarshal(buff, &h.privileges)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *PrivilegeHandler) Setup() []goamqp.Setup {
|
|
return []goamqp.Setup{
|
|
goamqp.TransientEventStreamConsumer("User.Added", process[UserAdded](h)),
|
|
goamqp.TransientEventStreamConsumer("User.Removed", process[UserRemoved](h)),
|
|
goamqp.TransientEventStreamConsumer("Privilege.Added", process[PrivilegeAdded](h)),
|
|
goamqp.TransientEventStreamConsumer("Privilege.Removed", process[PrivilegeRemoved](h)),
|
|
}
|
|
}
|
|
|
|
// privilegeEvent is the set of events Process handles.
|
|
type privilegeEvent interface {
|
|
UserAdded | UserRemoved | PrivilegeAdded | PrivilegeRemoved
|
|
}
|
|
|
|
// process adapts Process to a typed go-messaging-amqp handler.
|
|
func process[T privilegeEvent](h *PrivilegeHandler) spec.EventHandler[T] {
|
|
return func(_ context.Context, event spec.ConsumableEvent[T]) error {
|
|
return h.Process(&event.Payload)
|
|
}
|
|
}
|
|
|
|
// Process privilege-related events and update the internal state
|
|
func (h *PrivilegeHandler) Process(msg any) error {
|
|
h.Lock()
|
|
defer h.Unlock()
|
|
|
|
switch ev := msg.(type) {
|
|
case *UserAdded:
|
|
// Keep the privileges already recorded for the company. Each routing key has
|
|
// its own transient queue, so a Privilege.Added published after this event can
|
|
// be processed before it; overwriting the entry here would drop that privilege
|
|
// until the next Fetch, which only runs at start.
|
|
priv, exists := h.privileges[ev.Email]
|
|
if !exists {
|
|
priv = map[string]*CompanyPrivileges{}
|
|
h.privileges[ev.Email] = priv
|
|
}
|
|
if _, exists := priv[ev.CompanyID]; !exists {
|
|
priv[ev.CompanyID] = &CompanyPrivileges{}
|
|
}
|
|
return nil
|
|
case *UserRemoved:
|
|
if priv, exists := h.privileges[ev.Email]; exists {
|
|
delete(priv, ev.CompanyID)
|
|
}
|
|
return nil
|
|
case *PrivilegeAdded:
|
|
h.setPrivileges(ev.Email, ev.CompanyID, ev.Privilege, true)
|
|
return nil
|
|
case *PrivilegeRemoved:
|
|
h.setPrivileges(ev.Email, ev.CompanyID, ev.Privilege, false)
|
|
return nil
|
|
default:
|
|
fmt.Printf("Got unexpected message type (%s): '%+v'\n", reflect.TypeOf(msg).String(), msg)
|
|
return fmt.Errorf("unexpected event type: '%s'", reflect.TypeOf(msg))
|
|
}
|
|
}
|
|
|
|
func (h *PrivilegeHandler) setPrivileges(email, companyId string, privilege Privilege, set bool) {
|
|
if priv, exists := h.privileges[email]; exists {
|
|
if c, exists := priv[companyId]; exists {
|
|
switch privilege {
|
|
case PrivilegeAdmin:
|
|
c.Admin = set
|
|
case PrivilegeCompany:
|
|
c.Company = set
|
|
case PrivilegeConsumer:
|
|
c.Consumer = set
|
|
case PrivilegeTime:
|
|
c.Time = set
|
|
case PrivilegeInvoicing:
|
|
c.Invoicing = set
|
|
case PrivilegeAccounting:
|
|
c.Accounting = set
|
|
case PrivilegeSupplier:
|
|
c.Supplier = set
|
|
case PrivilegeSalary:
|
|
c.Salary = set
|
|
}
|
|
} else {
|
|
priv[companyId] = &CompanyPrivileges{}
|
|
h.setPrivileges(email, companyId, privilege, set)
|
|
}
|
|
} else {
|
|
h.privileges[email] = map[string]*CompanyPrivileges{}
|
|
h.setPrivileges(email, companyId, privilege, set)
|
|
}
|
|
}
|
|
|
|
// CompaniesByUser return a slice of company ids matching the provided email and predicate func
|
|
func (h *PrivilegeHandler) CompaniesByUser(email string, predicate func(privileges CompanyPrivileges) bool) []string {
|
|
h.RLock()
|
|
defer h.RUnlock()
|
|
var result []string
|
|
if p, exists := h.privileges[email]; exists {
|
|
for k, v := range p {
|
|
if predicate(*v) {
|
|
result = append(result, k)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// IsAllowed return true if the provided predicate return true for the privileges matching the provided email and companyID, return false otherwise
|
|
func (h *PrivilegeHandler) IsAllowed(email, companyID string, predicate func(privileges CompanyPrivileges) bool) bool {
|
|
h.RLock()
|
|
defer h.RUnlock()
|
|
if p, exists := h.privileges[email]; exists {
|
|
if v, exists := p[companyID]; exists {
|
|
return predicate(*v)
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|