Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99112033a6 | ||
|
|
ff93b10435 | ||
|
|
6a708965e4 | ||
|
|
bb450e5d8c | ||
|
|
206d2ab14b | ||
|
|
78b4730687 | ||
|
|
3941ff4950 | ||
|
|
09766ebe8b | ||
|
|
e0d1ce3b31 | ||
|
|
5100d53c76 | ||
|
|
01f30cfd2b | ||
|
|
9d1b981644 | ||
|
|
3347d598a4 | ||
|
|
6bdf6e1cd3 | ||
|
|
f5e9eb52a7 | ||
|
|
85d29c1196 | ||
|
|
37d9282f7e | ||
|
|
5c2477eded | ||
|
|
bfd5fd4c16 | ||
|
|
dfc666ebb0 | ||
|
|
75f87fd618 | ||
|
|
752f80ea96 | ||
|
|
08269c034b | ||
|
|
89fa8928dc | ||
|
|
6fccd2010c | ||
|
|
4296334275 | ||
|
|
daa836e97d | ||
|
|
f9a89b64be | ||
|
|
775d25cb59 | ||
|
|
ef992cb9db | ||
|
|
c3b8a3f1ce | ||
|
|
45512115c5 | ||
|
|
fe0abd62c8 | ||
|
|
a54cf45a4b | ||
|
|
f9a5ef7085 | ||
|
|
200e7cf963 | ||
|
|
110f6206f9 | ||
|
|
c53d80792c | ||
|
|
ebc0c3bb8e | ||
|
|
cb59762fc9 | ||
|
|
a82466cb27 | ||
|
|
29eab978f7 | ||
|
|
f3166426b6 | ||
|
|
3171c53393
|
||
|
|
7af8e00b4c | ||
|
|
0c0f321b33
|
||
|
|
87805f1552 | ||
|
|
74ee30bccc | ||
|
|
646e4f31c4 | ||
|
|
881fac379f |
No files matched your search
@@ -10,18 +10,69 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Run tests
|
||||
run: go test -race -coverprofile=coverage.txt ./...
|
||||
|
||||
- name: Check coverage
|
||||
id: coverage
|
||||
run: |
|
||||
go install github.com/vladopajic/go-test-coverage/v2@latest
|
||||
go-test-coverage --config ./.testcoverage.yml --github-action-output
|
||||
- name: Download baseline coverage
|
||||
if: gitea.event_name == 'pull_request'
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: coverage-baseline
|
||||
path: ./baseline
|
||||
continue-on-error: true
|
||||
- name: Compare coverage
|
||||
if: gitea.event_name == 'pull_request'
|
||||
run: |
|
||||
CURRENT="${{ steps.coverage.outputs.total-coverage }}"
|
||||
if [ -f ./baseline/coverage.txt ]; then
|
||||
BASE=$(cat ./baseline/coverage.txt)
|
||||
echo "Base coverage: ${BASE}%"
|
||||
echo "Current coverage: ${CURRENT}%"
|
||||
if [ "$(echo "$CURRENT < $BASE" | bc -l)" -eq 1 ]; then
|
||||
echo "::error::Coverage decreased from ${BASE}% to ${CURRENT}%"
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage maintained or improved: ${BASE}% -> ${CURRENT}%"
|
||||
else
|
||||
echo "No baseline coverage found, skipping comparison"
|
||||
echo "Current coverage: ${CURRENT}%"
|
||||
fi
|
||||
- name: Save coverage baseline
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
run: echo "${{ steps.coverage.outputs.total-coverage }}" > coverage.txt
|
||||
- name: Upload coverage baseline
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-baseline
|
||||
path: coverage.txt
|
||||
retention-days: 90
|
||||
- name: Post coverage comment
|
||||
if: gitea.event_name == 'pull_request'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
run: |
|
||||
COVERAGE="${{ steps.coverage.outputs.total-coverage }}"
|
||||
curl -X POST "${GITEA_URL}/api/v1/repos/${{ gitea.repository }}/issues/${{ gitea.event.pull_request.number }}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"## Coverage Report\n\nTotal coverage: **${COVERAGE}%**\"}"
|
||||
|
||||
vulnerabilities:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Check vulnerabilities
|
||||
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
env:
|
||||
SKIP: no-commit-to-branch
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Install goimports
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
include:
|
||||
- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
|
||||
- project: unboundsoftware/ci-templates
|
||||
file: Defaults.gitlab-ci.yml
|
||||
- project: unboundsoftware/ci-templates
|
||||
file: Release.gitlab-ci.yml
|
||||
- project: unboundsoftware/ci-templates
|
||||
file: Pre-Commit-Go.gitlab-ci.yml
|
||||
|
||||
image: amd64/golang:1.25.5@sha256:ad03ba93327b8a6143b49373790b5d92c28067bdb814418509466122ee9c9e63
|
||||
|
||||
stages:
|
||||
- deps
|
||||
- test
|
||||
|
||||
deps:
|
||||
stage: deps
|
||||
script:
|
||||
- go mod download
|
||||
|
||||
test:
|
||||
stage: test
|
||||
dependencies:
|
||||
- deps
|
||||
script:
|
||||
- CGO_ENABLED=1 go test -mod=readonly -race -coverprofile=coverage.txt -covermode=atomic -coverpkg=$(go list ./... | tr '\n' , | sed 's/,$//') ./...
|
||||
- go tool cover -html=coverage.txt -o coverage.html
|
||||
- go tool cover -func=coverage.txt
|
||||
- curl -Os https://uploader.codecov.io/latest/linux/codecov
|
||||
- chmod +x codecov
|
||||
- ./codecov -t ${CODECOV_TOKEN} -R $CI_PROJECT_DIR -C $CI_COMMIT_SHA -r $CI_PROJECT_PATH
|
||||
|
||||
vulnerabilities:
|
||||
stage: test
|
||||
image: amd64/golang:1.25.5@sha256:ad03ba93327b8a6143b49373790b5d92c28067bdb814418509466122ee9c9e63
|
||||
script:
|
||||
- go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- govulncheck ./...
|
||||
@@ -11,7 +11,7 @@ repos:
|
||||
- --allow-multiple-documents
|
||||
- id: check-added-large-files
|
||||
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
|
||||
rev: v9.23.0
|
||||
rev: v9.27.0
|
||||
hooks:
|
||||
- id: commitlint
|
||||
stages: [ commit-msg ]
|
||||
@@ -30,10 +30,10 @@ repos:
|
||||
- id: go-test
|
||||
- id: gofumpt
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.8.0
|
||||
rev: v2.14.0
|
||||
hooks:
|
||||
- id: golangci-lint-full
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
rev: v8.30.1
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
@@ -0,0 +1,13 @@
|
||||
# Coverage configuration for go-test-coverage
|
||||
# https://github.com/vladopajic/go-test-coverage
|
||||
|
||||
profile: coverage.txt
|
||||
|
||||
threshold:
|
||||
file: 0
|
||||
package: 0
|
||||
total: 0
|
||||
|
||||
exclude:
|
||||
paths:
|
||||
- _test\.go$
|
||||
@@ -2,6 +2,92 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.7.1] - 2026-10-05
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(deps)* Update module codeberg.org/messaging/go-messaging-amqp to v0.0.6 (#337)
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Deploy authz-service first when the /authz contract changes (#335)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v9.27.0 (#339)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.14.0 (#341)
|
||||
- *(renovate)* Group OpenTelemetry, otelsetup and logging updates (#343)
|
||||
|
||||
## [0.7.0] - 2026-09-16
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- [**breaking**] Order privilege events by sequence number and merge snapshots by position (#333)
|
||||
|
||||
## [0.6.1] - 2026-09-16
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Keep existing privileges when User.Added is processed late (#331)
|
||||
|
||||
## [0.6.0] - 2026-09-14
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- [**breaking**] Consume privilege events with go-messaging-amqp (#327)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.13.2 (#325)
|
||||
- Bump the minor version for breaking changes before 1.0.0 (#328)
|
||||
|
||||
## [0.5.1] - 2026-08-29
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(ci)* Use go-test-coverage binary directly to fix Gitea Actions (#303)
|
||||
- *(deps)* Update module github.com/stretchr/testify to v1.12.0 (#319)
|
||||
- *(deps)* Update module github.com/stretchr/testify to v1.12.1 (#321)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update pre-commit hook gitleaks/gitleaks to v8.30.1 (#296)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.11.4 (#298)
|
||||
- *(deps)* Update dependency go to v1.26.2 (#300)
|
||||
- *(deps)* Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v9.25.0 (#304)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.0 (#306)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.1 (#308)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.12.2 (#309)
|
||||
- *(deps)* Update actions/checkout action to v7 (#311)
|
||||
- *(deps)* Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v9.26.0 (#313)
|
||||
- *(deps)* Update actions/setup-go action to v7 (#315)
|
||||
- *(deps)* Update actions/setup-python action to v7 (#317)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.13.1 (#322)
|
||||
|
||||
## [0.5.0] - 2026-03-12
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(client)* Add API key authentication for /authz endpoint (#294)
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- *(deps)* Update golang:1.25.5 docker digest to 3a01526 (#271)
|
||||
- *(deps)* Update pre-commit hook alessandrojcm/commitlint-pre-commit-hook to v9.24.0 (#273)
|
||||
- *(deps)* Update dependency go to v1.25.6 (#274)
|
||||
- *(deps)* Update golang docker tag to v1.25.6 (#275)
|
||||
- Remove GitLab CI configuration
|
||||
- Add code coverage integration
|
||||
- *(deps)* Update dependency go to v1.25.7 (#279)
|
||||
- *(deps)* Update dependency go to v1.26.0 (#280)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.9.0 (#281)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.10.0 (#282)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.10.1 (#283)
|
||||
- *(deps)* Update dependency go to v1.26.1 (#286)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.11.1 (#288)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.11.2 (#290)
|
||||
- *(deps)* Update pre-commit hook golangci/golangci-lint to v2.11.3 (#292)
|
||||
|
||||
## [0.4.1] - 2026-01-09
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
@@ -23,10 +23,9 @@ import client "gitea.unbound.se/shiny/authz_client"
|
||||
handler := client.New(client.WithBaseURL("http://authz-service"))
|
||||
|
||||
// Check user privileges
|
||||
privileges := handler.Get(email, companyID)
|
||||
if privileges.Invoicing {
|
||||
// User has invoicing privileges
|
||||
}
|
||||
allowed := handler.IsAllowed(email, companyID, func(p client.CompanyPrivileges) bool {
|
||||
return p.Invoicing
|
||||
})
|
||||
```
|
||||
|
||||
### Privileges
|
||||
@@ -43,4 +42,10 @@ The `CompanyPrivileges` struct contains permission flags:
|
||||
|
||||
### Event Handling
|
||||
|
||||
Implements `goamqp` message handlers to receive privilege update events from the authz-service, keeping the local privilege cache up-to-date.
|
||||
Registers per-replica (transient) go-messaging-amqp consumers for privilege events from authz-service (`Setup()`). Each routing key gets its own queue, so events arrive in any order. `Process` orders them by the `sequenceNo` authz-service's event store stamps on every event: all four events come from authz-service's `Company` aggregate, so the global sequence number orders them within a company. Each (email, company) keeps the sequence number of the last fact about membership and about each privilege; an event only overrides older facts. `User.Removed` stamps every privilege, so a late grant can't come back. An event without `sequenceNo` fails closed: additions are dropped, and removals apply and block every later event for those facts until the next snapshot. An event with a negative or implausibly large `sequenceNo` is dropped. Tests that seed the handler must set `SequenceNo`.
|
||||
|
||||
### Startup
|
||||
|
||||
Call `Fetch()` **after** `conn.Start` (consumers bound), never before: events published between the snapshot and the binding would otherwise be lost. authz-service only serves a snapshot whose read view has applied every stored event. It sends that position in the `X-Authz-Sequence` header and answers 503 while the read view lags (backlog, backfill, reset); `Fetch` retries 503 for about a minute. `Fetch` merges the snapshot as facts at that sequence number (newer event facts win, pairs missing from the snapshot are removed) and drops later events at or below it. A snapshot older than one already merged is ignored, so concurrent or repeated fetches are safe. A snapshot without the header merges at 0 and logs a warning (rollout window only). When the `/authz` contract changes, deploy authz-service before the services that use this library.
|
||||
|
||||
Don't combine `Setup()` with go-messaging-amqp's `WithReconnect`: a reconnect declares new per-replica queues, so revocations published during the outage are lost unless `Fetch()` runs again. Services exit on connection loss (`CloseListener`) and re-fetch on start.
|
||||
@@ -1,16 +1,31 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sparetimecoders/goamqp"
|
||||
goamqp "codeberg.org/messaging/go-messaging-amqp"
|
||||
spec "codeberg.org/messaging/messaging"
|
||||
)
|
||||
|
||||
// SequenceHeader carries the global sequence number of the authz-service read view the
|
||||
// /authz snapshot was read at.
|
||||
const SequenceHeader = "X-Authz-Sequence"
|
||||
|
||||
// pending marks a fact cleared by an event without a sequence number. No event can
|
||||
// override it; only a snapshot can.
|
||||
const pending = math.MaxInt
|
||||
|
||||
// CompanyPrivileges contains the privileges for a combination of email address and company id
|
||||
type CompanyPrivileges struct {
|
||||
Admin bool `json:"admin"`
|
||||
@@ -23,12 +38,84 @@ type CompanyPrivileges struct {
|
||||
Salary bool `json:"salary"`
|
||||
}
|
||||
|
||||
// field returns the flag for privilege, or nil for an unknown privilege.
|
||||
func (c *CompanyPrivileges) field(privilege Privilege) *bool {
|
||||
switch privilege {
|
||||
case PrivilegeAdmin:
|
||||
return &c.Admin
|
||||
case PrivilegeCompany:
|
||||
return &c.Company
|
||||
case PrivilegeConsumer:
|
||||
return &c.Consumer
|
||||
case PrivilegeTime:
|
||||
return &c.Time
|
||||
case PrivilegeInvoicing:
|
||||
return &c.Invoicing
|
||||
case PrivilegeAccounting:
|
||||
return &c.Accounting
|
||||
case PrivilegeSupplier:
|
||||
return &c.Supplier
|
||||
case PrivilegeSalary:
|
||||
return &c.Salary
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// companyState is what the handler knows about one email in one company. Every
|
||||
// privilege event for a company comes from authz-service's Company aggregate, so
|
||||
// its global sequence number orders the events for the pair. The four routing
|
||||
// keys arrive on separate queues in any order; a fact is only overwritten by a
|
||||
// fact with a higher sequence number.
|
||||
type companyState struct {
|
||||
privileges CompanyPrivileges
|
||||
// privilegeSeq is the sequence number of the last fact about each privilege.
|
||||
privilegeSeq map[Privilege]int
|
||||
// member is whether the user belongs to the company, as of memberSeq. A
|
||||
// User.Removed stamps every privilege with its sequence number, so a grant
|
||||
// older than the removal can't come back.
|
||||
member bool
|
||||
memberSeq int
|
||||
}
|
||||
|
||||
func newCompanyState() *companyState {
|
||||
return &companyState{privilegeSeq: map[Privilege]int{}}
|
||||
}
|
||||
|
||||
// clearPending turns facts cleared without a sequence number into unordered facts, so
|
||||
// the snapshot being merged replaces them.
|
||||
func (s *companyState) clearPending() {
|
||||
if s.memberSeq == pending {
|
||||
s.memberSeq = 0
|
||||
}
|
||||
for p, seq := range s.privilegeSeq {
|
||||
if seq == pending {
|
||||
s.privilegeSeq[p] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maxSeq is the highest sequence number of any fact in the state.
|
||||
func (s *companyState) maxSeq() int {
|
||||
m := s.memberSeq
|
||||
for _, seq := range s.privilegeSeq {
|
||||
m = max(m, seq)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 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
|
||||
privileges map[string]map[string]*CompanyPrivileges
|
||||
client *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
state map[string]map[string]*companyState
|
||||
// floor is the sequence number of the newest snapshot. Its effects are all in
|
||||
// the state, so an event at or below it is stale.
|
||||
floor int
|
||||
// retries is how many times Fetch retries a snapshot authz-service isn't ready to serve.
|
||||
retries int
|
||||
retryDelay time.Duration
|
||||
}
|
||||
|
||||
// OptsFunc is used to configure the PrivilegeHandler
|
||||
@@ -41,13 +128,22 @@ func WithBaseURL(url string) OptsFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 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{},
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: "http://authz-service",
|
||||
privileges: map[string]map[string]*CompanyPrivileges{},
|
||||
state: map[string]map[string]*companyState{},
|
||||
retries: 60,
|
||||
retryDelay: time.Second,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(handler)
|
||||
@@ -55,101 +151,250 @@ func New(opts ...OptsFunc) *PrivilegeHandler {
|
||||
return handler
|
||||
}
|
||||
|
||||
// Fetch the initial set of privileges from an authz-service
|
||||
// Fetch a snapshot of all privileges from an authz-service and merge it into the state.
|
||||
//
|
||||
// Call it after the AMQP connection has started: authz-service only serves a snapshot
|
||||
// that includes every stored event, so together with the bound queues no event is
|
||||
// missed. While its read view is behind it answers 503, and Fetch retries. The snapshot
|
||||
// is applied as facts at its sequence number: a fact from an event newer than the
|
||||
// snapshot is kept, everything else is replaced, and a pair missing from the snapshot is
|
||||
// removed. A snapshot older than one already merged is ignored.
|
||||
func (h *PrivilegeHandler) Fetch() error {
|
||||
resp, err := h.client.Get(fmt.Sprintf("%s/authz", h.baseURL))
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := h.fetch()
|
||||
if !errors.Is(err, errNotReady) || attempt >= h.retries {
|
||||
return err
|
||||
}
|
||||
time.Sleep(h.retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
var errNotReady = errors.New("fetch privileges: authz-service read view not ready")
|
||||
|
||||
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
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
return errNotReady
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("fetch privileges: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
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 {
|
||||
var snapshot map[string]map[string]CompanyPrivileges
|
||||
if err := json.Unmarshal(buff, &snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seq := 0
|
||||
if v := resp.Header.Get(SequenceHeader); v != "" {
|
||||
seq, err = strconv.Atoi(v)
|
||||
if err != nil || seq < 0 {
|
||||
return fmt.Errorf("fetch privileges: invalid %s header %q", SequenceHeader, v)
|
||||
}
|
||||
} else {
|
||||
// ponytail: only during the rollout window, before authz-service sends the header.
|
||||
slog.Warn("authz snapshot has no sequence number; events older than it can revert it", "header", SequenceHeader)
|
||||
}
|
||||
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
h.merge(snapshot, seq)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *PrivilegeHandler) merge(snapshot map[string]map[string]CompanyPrivileges, seq int) {
|
||||
if seq < h.floor {
|
||||
// A newer snapshot is already merged; this one could only bring back removed state.
|
||||
slog.Warn("ignoring authz snapshot older than the one already merged", "seq", seq, "floor", h.floor)
|
||||
return
|
||||
}
|
||||
for email, companies := range h.state {
|
||||
for companyID, s := range companies {
|
||||
s.clearPending()
|
||||
if _, exists := snapshot[email][companyID]; exists {
|
||||
continue
|
||||
}
|
||||
if s.maxSeq() <= seq {
|
||||
// Nothing newer than the snapshot; the floor keeps stale events out.
|
||||
delete(companies, companyID)
|
||||
continue
|
||||
}
|
||||
removeUser(s, seq, false)
|
||||
}
|
||||
if len(companies) == 0 {
|
||||
delete(h.state, email)
|
||||
}
|
||||
}
|
||||
for email, companies := range snapshot {
|
||||
for companyID, privileges := range companies {
|
||||
s := h.company(email, companyID)
|
||||
if s.memberSeq <= seq {
|
||||
s.member = true
|
||||
s.memberSeq = seq
|
||||
}
|
||||
for _, p := range AllPrivilege {
|
||||
if s.privilegeSeq[p] <= seq {
|
||||
*s.privileges.field(p) = *privileges.field(p)
|
||||
s.privilegeSeq[p] = seq
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.floor = max(h.floor, seq)
|
||||
}
|
||||
|
||||
func (h *PrivilegeHandler) Setup() []goamqp.Setup {
|
||||
return []goamqp.Setup{
|
||||
goamqp.TransientEventStreamConsumer("User.Added", h.Process, UserAdded{}),
|
||||
goamqp.TransientEventStreamConsumer("User.Removed", h.Process, UserRemoved{}),
|
||||
goamqp.TransientEventStreamConsumer("Privilege.Added", h.Process, PrivilegeAdded{}),
|
||||
goamqp.TransientEventStreamConsumer("Privilege.Removed", h.Process, PrivilegeRemoved{}),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
// Process privilege-related events and update the internal state
|
||||
func (h *PrivilegeHandler) Process(msg interface{}, _ goamqp.Headers) (interface{}, error) {
|
||||
// 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.
|
||||
//
|
||||
// Events are applied by sequence number, not by arrival order. An event without a
|
||||
// sequence number can't be ordered, so it fails closed: a removal is applied and an
|
||||
// addition is dropped.
|
||||
func (h *PrivilegeHandler) Process(msg any) error {
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
|
||||
switch ev := msg.(type) {
|
||||
case *UserAdded:
|
||||
if priv, exists := h.privileges[ev.Email]; exists {
|
||||
priv[ev.CompanyID] = &CompanyPrivileges{}
|
||||
} else {
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
h.privileges[ev.Email] = map[string]*CompanyPrivileges{
|
||||
ev.CompanyID: {},
|
||||
}
|
||||
if h.stale(ev.SequenceNo, true, ev) {
|
||||
return nil
|
||||
}
|
||||
return nil, nil
|
||||
s := h.company(ev.Email, ev.CompanyID)
|
||||
if ev.SequenceNo > s.memberSeq {
|
||||
s.member = true
|
||||
s.memberSeq = ev.SequenceNo
|
||||
}
|
||||
return nil
|
||||
case *UserRemoved:
|
||||
if priv, exists := h.privileges[ev.Email]; exists {
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
delete(priv, ev.CompanyID)
|
||||
if h.stale(ev.SequenceNo, false, ev) {
|
||||
return nil
|
||||
}
|
||||
return nil, nil
|
||||
removeUser(h.company(ev.Email, ev.CompanyID), ev.SequenceNo, ev.SequenceNo == 0)
|
||||
return nil
|
||||
case *PrivilegeAdded:
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
h.setPrivileges(ev.Email, ev.CompanyID, ev.Privilege, true)
|
||||
return nil, nil
|
||||
if h.stale(ev.SequenceNo, true, ev) {
|
||||
return nil
|
||||
}
|
||||
h.setPrivilege(ev.Email, ev.CompanyID, ev.Privilege, ev.SequenceNo, true)
|
||||
return nil
|
||||
case *PrivilegeRemoved:
|
||||
h.Lock()
|
||||
defer h.Unlock()
|
||||
h.setPrivileges(ev.Email, ev.CompanyID, ev.Privilege, false)
|
||||
return nil, nil
|
||||
if h.stale(ev.SequenceNo, false, ev) {
|
||||
return nil
|
||||
}
|
||||
h.setPrivilege(ev.Email, ev.CompanyID, ev.Privilege, ev.SequenceNo, false)
|
||||
return nil
|
||||
default:
|
||||
fmt.Printf("Got unexpected message type (%s): '%+v'\n", reflect.TypeOf(msg).String(), msg)
|
||||
return nil, fmt.Errorf("unexpected event type: '%s'", reflect.TypeOf(msg))
|
||||
slog.Error("unexpected privilege message type", "type", reflect.TypeOf(msg).String())
|
||||
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)
|
||||
// stale reports whether an event must be skipped: an addition without a sequence
|
||||
// number, or any event already covered by the latest snapshot.
|
||||
func (h *PrivilegeHandler) stale(seq int, addition bool, ev any) bool {
|
||||
if seq < 0 || seq >= pending {
|
||||
slog.Error("dropping privilege event with invalid sequence number", "type", reflect.TypeOf(ev).String(), "seq", seq)
|
||||
return true
|
||||
}
|
||||
if seq == 0 {
|
||||
if addition {
|
||||
slog.Warn("dropping privilege addition without sequence number", "type", reflect.TypeOf(ev).String())
|
||||
}
|
||||
} else {
|
||||
h.privileges[email] = map[string]*CompanyPrivileges{}
|
||||
h.setPrivileges(email, companyId, privilege, set)
|
||||
return addition
|
||||
}
|
||||
return seq <= h.floor
|
||||
}
|
||||
|
||||
func (h *PrivilegeHandler) company(email, companyID string) *companyState {
|
||||
companies, exists := h.state[email]
|
||||
if !exists {
|
||||
companies = map[string]*companyState{}
|
||||
h.state[email] = companies
|
||||
}
|
||||
s, exists := companies[companyID]
|
||||
if !exists {
|
||||
s = newCompanyState()
|
||||
companies[companyID] = s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// removeUser removes the user at seq, overriding every fact older than seq. An
|
||||
// unordered removal (all) overrides every fact and marks them pending, so no event can
|
||||
// bring them back before the next snapshot.
|
||||
func removeUser(s *companyState, seq int, all bool) {
|
||||
stamp := seq
|
||||
if all {
|
||||
stamp = pending
|
||||
}
|
||||
if all || s.memberSeq < seq {
|
||||
s.member = false
|
||||
s.memberSeq = max(s.memberSeq, stamp)
|
||||
}
|
||||
for _, p := range AllPrivilege {
|
||||
if all || s.privilegeSeq[p] < seq {
|
||||
*s.privileges.field(p) = false
|
||||
s.privilegeSeq[p] = max(s.privilegeSeq[p], stamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PrivilegeHandler) setPrivilege(email, companyID string, privilege Privilege, seq int, set bool) {
|
||||
if !privilege.IsValid() {
|
||||
return
|
||||
}
|
||||
s := h.company(email, companyID)
|
||||
if seq != 0 && seq <= s.privilegeSeq[privilege] {
|
||||
return
|
||||
}
|
||||
*s.privileges.field(privilege) = set
|
||||
if seq == 0 {
|
||||
// Only an unordered removal gets here; keep it until the next snapshot.
|
||||
seq = pending
|
||||
}
|
||||
s.privilegeSeq[privilege] = max(s.privilegeSeq[privilege], seq)
|
||||
// authz-service's aggregate adds the user when a privilege is granted.
|
||||
if set && seq > s.memberSeq {
|
||||
s.member = true
|
||||
s.memberSeq = seq
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,12 +402,10 @@ func (h *PrivilegeHandler) setPrivileges(email, companyId string, privilege Priv
|
||||
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)
|
||||
}
|
||||
result := []string{}
|
||||
for k, s := range h.state[email] {
|
||||
if s.member && predicate(s.privileges) {
|
||||
result = append(result, k)
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -172,11 +415,8 @@ func (h *PrivilegeHandler) CompaniesByUser(email string, predicate func(privileg
|
||||
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)
|
||||
}
|
||||
if s, exists := h.state[email][companyID]; exists && s.member {
|
||||
return predicate(s.privileges)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
+543
-96
@@ -1,35 +1,40 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sparetimecoders/goamqp"
|
||||
goamqp "codeberg.org/messaging/go-messaging-amqp"
|
||||
spec "codeberg.org/messaging/messaging"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrivilegeHandler_Process_InvalidType(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
result, err := handler.Process("abc", goamqp.Headers{})
|
||||
err := handler.Process("abc")
|
||||
|
||||
assert.Nil(t, result)
|
||||
assert.EqualError(t, err, "unexpected event type: 'string'")
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Process_PrivilegeRemoved(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
result, err := handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeAdmin,
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err := handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeAdmin,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies := handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -38,12 +43,12 @@ func TestPrivilegeHandler_Process_PrivilegeRemoved(t *testing.T) {
|
||||
|
||||
assert.Equal(t, []string{"abc-123"}, companies)
|
||||
|
||||
result, err = handler.Process(&PrivilegeRemoved{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeAdmin,
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err = handler.Process(&PrivilegeRemoved{
|
||||
SequenceNo: 2,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeAdmin,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies = handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -56,18 +61,18 @@ func TestPrivilegeHandler_Process_PrivilegeRemoved(t *testing.T) {
|
||||
func TestPrivilegeHandler_Process_UserAdded_And_UserRemoved(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
result, err := handler.Process(&UserAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err := handler.Process(&UserAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
result, err = handler.Process(&UserAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-456",
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err = handler.Process(&UserAdded{
|
||||
SequenceNo: 2,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-456",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies := handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -76,18 +81,18 @@ func TestPrivilegeHandler_Process_UserAdded_And_UserRemoved(t *testing.T) {
|
||||
sort.Strings(companies)
|
||||
assert.Equal(t, []string{"abc-123", "abc-456"}, companies)
|
||||
|
||||
result, err = handler.Process(&UserRemoved{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err = handler.Process(&UserRemoved{
|
||||
SequenceNo: 3,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
result, err = handler.Process(&UserRemoved{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-456",
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err = handler.Process(&UserRemoved{
|
||||
SequenceNo: 4,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-456",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies = handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -96,6 +101,51 @@ func TestPrivilegeHandler_Process_UserAdded_And_UserRemoved(t *testing.T) {
|
||||
assert.Empty(t, companies)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Process_UserAdded_Keeps_Existing_Privileges(t *testing.T) {
|
||||
// Each routing key has its own transient queue, so Privilege.Added can be
|
||||
// processed before the User.Added published before it. The privilege must
|
||||
// survive either order: nothing re-reads it until the next Fetch at start.
|
||||
userAdded := &UserAdded{SequenceNo: 1, Email: "jim@example.org", CompanyID: "abc-123"}
|
||||
privilegeAdded := &PrivilegeAdded{SequenceNo: 2, Email: "jim@example.org", CompanyID: "abc-123", Privilege: PrivilegeAdmin}
|
||||
for name, order := range map[string][]any{
|
||||
"user added first": {userAdded, privilegeAdded},
|
||||
"privilege added first": {privilegeAdded, userAdded},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
for _, event := range order {
|
||||
require.NoError(t, handler.Process(event))
|
||||
}
|
||||
|
||||
assert.True(t, handler.IsAllowed("jim@example.org", "abc-123", func(privileges CompanyPrivileges) bool {
|
||||
return privileges.Admin
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Process_UserAdded_After_UserRemoved_Starts_Empty(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
assert.NoError(t, handler.Process(&UserAdded{SequenceNo: 1, Email: "jim@example.org", CompanyID: "abc-123"}))
|
||||
assert.NoError(t, handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 2,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeAdmin,
|
||||
}))
|
||||
assert.NoError(t, handler.Process(&UserRemoved{SequenceNo: 3, Email: "jim@example.org", CompanyID: "abc-123"}))
|
||||
assert.NoError(t, handler.Process(&UserAdded{SequenceNo: 4, Email: "jim@example.org", CompanyID: "abc-123"}))
|
||||
|
||||
// Membership is back, without the privileges the removal took away.
|
||||
assert.Equal(t, []string{"abc-123"}, handler.CompaniesByUser("jim@example.org", func(CompanyPrivileges) bool {
|
||||
return true
|
||||
}))
|
||||
assert.False(t, handler.IsAllowed("jim@example.org", "abc-123", func(privileges CompanyPrivileges) bool {
|
||||
return privileges.Admin
|
||||
}))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_GetCompanies_Email_Not_Found(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
@@ -109,11 +159,11 @@ func TestPrivilegeHandler_GetCompanies_Email_Not_Found(t *testing.T) {
|
||||
func TestPrivilegeHandler_GetCompanies_No_Companies_Found(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
result, err := handler.Process(&UserAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err := handler.Process(&UserAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies := handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -128,11 +178,11 @@ func TestPrivilegeHandler_GetCompanies_No_Companies_Found(t *testing.T) {
|
||||
|
||||
assert.Equal(t, []string{"abc-123"}, companies)
|
||||
|
||||
result, err = handler.Process(&UserRemoved{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err = handler.Process(&UserRemoved{
|
||||
SequenceNo: 2,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies = handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -144,12 +194,12 @@ func TestPrivilegeHandler_GetCompanies_No_Companies_Found(t *testing.T) {
|
||||
func TestPrivilegeHandler_GetCompanies_Company_With_Company_Access_Found(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
result, err := handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeCompany,
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err := handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeCompany,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies := handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -162,12 +212,12 @@ func TestPrivilegeHandler_GetCompanies_Company_With_Company_Access_Found(t *test
|
||||
func TestPrivilegeHandler_GetCompanies_Company_With_Admin_Access_Found(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
result, err := handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeConsumer,
|
||||
}, goamqp.Headers{})
|
||||
assert.Nil(t, result)
|
||||
err := handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeConsumer,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
companies := handler.CompaniesByUser("jim@example.org", func(privileges CompanyPrivileges) bool {
|
||||
@@ -190,11 +240,12 @@ func TestPrivilegeHandler_IsAllowed_Return_False_If_No_Privileges(t *testing.T)
|
||||
func TestPrivilegeHandler_IsAllowed_Return_True_If_Privilege_Exists(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
_, _ = handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeTime,
|
||||
}, goamqp.Headers{})
|
||||
_ = handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeTime,
|
||||
})
|
||||
|
||||
result := handler.IsAllowed("jim@example.org", "abc-123", func(privileges CompanyPrivileges) bool {
|
||||
return privileges.Time
|
||||
@@ -202,11 +253,12 @@ func TestPrivilegeHandler_IsAllowed_Return_True_If_Privilege_Exists(t *testing.T
|
||||
|
||||
assert.True(t, result)
|
||||
|
||||
_, _ = handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeInvoicing,
|
||||
}, goamqp.Headers{})
|
||||
_ = handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 2,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeInvoicing,
|
||||
})
|
||||
|
||||
result = handler.IsAllowed("jim@example.org", "abc-123", func(privileges CompanyPrivileges) bool {
|
||||
return privileges.Invoicing
|
||||
@@ -214,11 +266,12 @@ func TestPrivilegeHandler_IsAllowed_Return_True_If_Privilege_Exists(t *testing.T
|
||||
|
||||
assert.True(t, result)
|
||||
|
||||
_, _ = handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeAccounting,
|
||||
}, goamqp.Headers{})
|
||||
_ = handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 3,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeAccounting,
|
||||
})
|
||||
|
||||
result = handler.IsAllowed("jim@example.org", "abc-123", func(privileges CompanyPrivileges) bool {
|
||||
return privileges.Accounting
|
||||
@@ -226,11 +279,12 @@ func TestPrivilegeHandler_IsAllowed_Return_True_If_Privilege_Exists(t *testing.T
|
||||
|
||||
assert.True(t, result)
|
||||
|
||||
_, _ = handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeSupplier,
|
||||
}, goamqp.Headers{})
|
||||
_ = handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 4,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeSupplier,
|
||||
})
|
||||
|
||||
result = handler.IsAllowed("jim@example.org", "abc-123", func(privileges CompanyPrivileges) bool {
|
||||
return privileges.Supplier
|
||||
@@ -238,11 +292,12 @@ func TestPrivilegeHandler_IsAllowed_Return_True_If_Privilege_Exists(t *testing.T
|
||||
|
||||
assert.True(t, result)
|
||||
|
||||
_, _ = handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeSalary,
|
||||
}, goamqp.Headers{})
|
||||
_ = handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 5,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: "abc-123",
|
||||
Privilege: PrivilegeSalary,
|
||||
})
|
||||
|
||||
result = handler.IsAllowed("jim@example.org", "abc-123", func(privileges CompanyPrivileges) bool {
|
||||
return privileges.Salary
|
||||
@@ -251,6 +306,39 @@ func TestPrivilegeHandler_IsAllowed_Return_True_If_Privilege_Exists(t *testing.T
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Sends_Authorization_Header_When_APIKey_Set(t *testing.T) {
|
||||
var receivedAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
handler := New(
|
||||
WithBaseURL(server.URL),
|
||||
WithAPIKey("my-secret-key"),
|
||||
)
|
||||
|
||||
err := handler.Fetch()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Bearer my-secret-key", receivedAuth)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_No_Authorization_Header_Without_APIKey(t *testing.T) {
|
||||
var receivedAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
_, _ = w.Write([]byte("{}"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
handler := New(WithBaseURL(server.URL))
|
||||
|
||||
err := handler.Fetch()
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, receivedAuth)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Error_Response(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
@@ -317,7 +405,7 @@ func TestPrivilegeHandler_Fetch_Valid(t *testing.T) {
|
||||
|
||||
err := handler.Fetch()
|
||||
assert.NoError(t, err)
|
||||
expectedPrivileges := map[string]map[string]*CompanyPrivileges{
|
||||
expectedPrivileges := map[string]map[string]CompanyPrivileges{
|
||||
"jim@example.org": {
|
||||
"00010203-0405-4607-8809-0a0b0c0d0e0f": {
|
||||
Admin: false,
|
||||
@@ -331,7 +419,7 @@ func TestPrivilegeHandler_Fetch_Valid(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedPrivileges, handler.privileges)
|
||||
assert.Equal(t, expectedPrivileges, privilegesOf(handler))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Concurrent_Fetches(t *testing.T) {
|
||||
@@ -381,7 +469,7 @@ func TestPrivilegeHandler_Fetch_Concurrent_Fetches(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify privileges were set correctly
|
||||
expectedPrivileges := map[string]map[string]*CompanyPrivileges{
|
||||
expectedPrivileges := map[string]map[string]CompanyPrivileges{
|
||||
"jim@example.org": {
|
||||
"00010203-0405-4607-8809-0a0b0c0d0e0f": {
|
||||
Admin: false,
|
||||
@@ -395,7 +483,7 @@ func TestPrivilegeHandler_Fetch_Concurrent_Fetches(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expectedPrivileges, handler.privileges)
|
||||
assert.Equal(t, expectedPrivileges, privilegesOf(handler))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Concurrent_Fetch_And_Read(t *testing.T) {
|
||||
@@ -489,11 +577,12 @@ func TestPrivilegeHandler_Concurrent_Process_And_Read(t *testing.T) {
|
||||
companyID := fmt.Sprintf("company-%d", i%10)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
_, _ = handler.Process(&PrivilegeAdded{
|
||||
Email: "jim@example.org",
|
||||
CompanyID: id,
|
||||
Privilege: PrivilegeAdmin,
|
||||
}, goamqp.Headers{})
|
||||
_ = handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jim@example.org",
|
||||
CompanyID: id,
|
||||
Privilege: PrivilegeAdmin,
|
||||
})
|
||||
}(companyID)
|
||||
}
|
||||
|
||||
@@ -565,11 +654,12 @@ func TestPrivilegeHandler_Concurrent_Multiple_Operations(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_, _ = handler.Process(&PrivilegeAdded{
|
||||
Email: "jane@example.org",
|
||||
CompanyID: fmt.Sprintf("company-%d", idx%5),
|
||||
Privilege: PrivilegeCompany,
|
||||
}, goamqp.Headers{})
|
||||
_ = handler.Process(&PrivilegeAdded{
|
||||
SequenceNo: 1,
|
||||
Email: "jane@example.org",
|
||||
CompanyID: fmt.Sprintf("company-%d", idx%5),
|
||||
Privilege: PrivilegeCompany,
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
|
||||
@@ -615,3 +705,360 @@ func TestPrivilegeHandler_Concurrent_Multiple_Operations(t *testing.T) {
|
||||
expectedJane := []string{"company-0", "company-1", "company-2", "company-3", "company-4"}
|
||||
assert.Equal(t, expectedJane, janeCompanies)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Setup(t *testing.T) {
|
||||
handler := New(WithBaseURL("base"))
|
||||
|
||||
topology, err := goamqp.CollectTopology("some-service", handler.Setup()...)
|
||||
assert.NoError(t, err)
|
||||
wiring := map[string]string{}
|
||||
for _, e := range topology.Endpoints {
|
||||
assert.Equal(t, spec.DirectionConsume, e.Direction)
|
||||
assert.True(t, e.Ephemeral, "%s must be a per-replica consumer", e.RoutingKey)
|
||||
wiring[e.RoutingKey] = e.MessageType
|
||||
}
|
||||
// A key wired to the wrong type could turn a revocation into a grant.
|
||||
assert.Equal(t, map[string]string{
|
||||
"User.Added": "client.UserAdded",
|
||||
"User.Removed": "client.UserRemoved",
|
||||
"Privilege.Added": "client.PrivilegeAdded",
|
||||
"Privilege.Removed": "client.PrivilegeRemoved",
|
||||
}, wiring)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_process(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
handler := New(WithBaseURL("base"))
|
||||
admin := func(p CompanyPrivileges) bool { return p.Admin }
|
||||
|
||||
assert.NoError(t, process[UserAdded](handler)(ctx, spec.ConsumableEvent[UserAdded]{
|
||||
Payload: UserAdded{SequenceNo: 1, Email: "jim@example.org", CompanyID: "abc-123"},
|
||||
}))
|
||||
assert.False(t, handler.IsAllowed("jim@example.org", "abc-123", admin))
|
||||
|
||||
assert.NoError(t, process[PrivilegeAdded](handler)(ctx, spec.ConsumableEvent[PrivilegeAdded]{
|
||||
Payload: PrivilegeAdded{SequenceNo: 2, Email: "jim@example.org", CompanyID: "abc-123", Privilege: PrivilegeAdmin},
|
||||
}))
|
||||
assert.True(t, handler.IsAllowed("jim@example.org", "abc-123", admin))
|
||||
|
||||
assert.NoError(t, process[PrivilegeRemoved](handler)(ctx, spec.ConsumableEvent[PrivilegeRemoved]{
|
||||
Payload: PrivilegeRemoved{SequenceNo: 3, Email: "jim@example.org", CompanyID: "abc-123", Privilege: PrivilegeAdmin},
|
||||
}))
|
||||
assert.False(t, handler.IsAllowed("jim@example.org", "abc-123", admin))
|
||||
|
||||
assert.NoError(t, process[UserRemoved](handler)(ctx, spec.ConsumableEvent[UserRemoved]{
|
||||
Payload: UserRemoved{SequenceNo: 4, Email: "jim@example.org", CompanyID: "abc-123"},
|
||||
}))
|
||||
assert.Empty(t, handler.CompaniesByUser("jim@example.org", func(CompanyPrivileges) bool { return true }))
|
||||
}
|
||||
|
||||
// privilegesOf returns the privileges of every membership in the handler.
|
||||
func privilegesOf(h *PrivilegeHandler) map[string]map[string]CompanyPrivileges {
|
||||
h.RLock()
|
||||
defer h.RUnlock()
|
||||
result := map[string]map[string]CompanyPrivileges{}
|
||||
for email, companies := range h.state {
|
||||
for companyID, s := range companies {
|
||||
if !s.member {
|
||||
continue
|
||||
}
|
||||
if result[email] == nil {
|
||||
result[email] = map[string]CompanyPrivileges{}
|
||||
}
|
||||
result[email][companyID] = s.privileges
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Process_Orders_By_SequenceNo(t *testing.T) {
|
||||
const email, company = "jim@example.org", "abc-123"
|
||||
userAdded := func(seq int) any { return &UserAdded{Email: email, CompanyID: company, SequenceNo: seq} }
|
||||
userRemoved := func(seq int) any { return &UserRemoved{Email: email, CompanyID: company, SequenceNo: seq} }
|
||||
added := func(seq int) any {
|
||||
return &PrivilegeAdded{Email: email, CompanyID: company, Privilege: PrivilegeAdmin, SequenceNo: seq}
|
||||
}
|
||||
removed := func(seq int) any {
|
||||
return &PrivilegeRemoved{Email: email, CompanyID: company, Privilege: PrivilegeAdmin, SequenceNo: seq}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
delivered []any
|
||||
member bool
|
||||
admin bool
|
||||
}{
|
||||
{name: "in order", delivered: []any{userAdded(1), added(2)}, member: true, admin: true},
|
||||
{name: "privilege before older user added keeps the grant", delivered: []any{added(2), userAdded(1)}, member: true, admin: true},
|
||||
{name: "late privilege added after user removed", delivered: []any{userAdded(1), userRemoved(3), added(2)}, member: false, admin: false},
|
||||
{name: "late privilege added after privilege removed", delivered: []any{userAdded(1), removed(3), added(2)}, member: true, admin: false},
|
||||
{name: "late user added does not revive privileges removed with the user", delivered: []any{userRemoved(3), added(2), userAdded(1)}, member: false, admin: false},
|
||||
{name: "re-added user starts without the removed privileges", delivered: []any{added(2), userRemoved(3), userAdded(4)}, member: true, admin: false},
|
||||
{name: "privilege granted after re-add", delivered: []any{userAdded(4), userRemoved(3), added(5), added(2)}, member: true, admin: true},
|
||||
{name: "late user removed does not remove a newer grant", delivered: []any{added(4), userRemoved(3)}, member: true, admin: true},
|
||||
{name: "late user removed does not remove a newer membership", delivered: []any{userAdded(4), userRemoved(3)}, member: true, admin: false},
|
||||
{name: "late privilege added before re-add stays revoked", delivered: []any{userAdded(1), userRemoved(3), added(2), userAdded(4)}, member: true, admin: false},
|
||||
{name: "privilege removed after user removed keeps the user out", delivered: []any{userRemoved(2), removed(3)}, member: false, admin: false},
|
||||
{name: "duplicate delivery is idempotent", delivered: []any{added(2), removed(3), added(2)}, member: true, admin: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := New()
|
||||
for _, event := range tt.delivered {
|
||||
require.NoError(t, handler.Process(event))
|
||||
}
|
||||
all := func(CompanyPrivileges) bool { return true }
|
||||
assert.Equal(t, tt.member, handler.IsAllowed(email, company, all))
|
||||
assert.Equal(t, tt.admin, handler.IsAllowed(email, company, func(p CompanyPrivileges) bool { return p.Admin }))
|
||||
if tt.member {
|
||||
assert.Equal(t, []string{company}, handler.CompaniesByUser(email, all))
|
||||
} else {
|
||||
assert.Equal(t, []string{}, handler.CompaniesByUser(email, all))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Process_Without_SequenceNo_Fails_Closed(t *testing.T) {
|
||||
const email, company = "jim@example.org", "abc-123"
|
||||
admin := func(p CompanyPrivileges) bool { return p.Admin }
|
||||
handler := New()
|
||||
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: company, Privilege: PrivilegeAdmin}))
|
||||
require.NoError(t, handler.Process(&UserAdded{Email: email, CompanyID: company}))
|
||||
assert.False(t, handler.IsAllowed(email, company, func(CompanyPrivileges) bool { return true }), "an unordered addition is dropped")
|
||||
require.NoError(t, handler.Process(&UserAdded{Email: email, CompanyID: company, SequenceNo: 1}))
|
||||
assert.False(t, handler.IsAllowed(email, company, admin), "the dropped grant doesn't appear with a later membership")
|
||||
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: company, Privilege: PrivilegeAdmin, SequenceNo: 5}))
|
||||
require.NoError(t, handler.Process(&PrivilegeRemoved{Email: email, CompanyID: company, Privilege: PrivilegeAdmin}))
|
||||
assert.False(t, handler.IsAllowed(email, company, admin), "an unordered privilege removal applies")
|
||||
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: company, Privilege: PrivilegeTime, SequenceNo: 6}))
|
||||
require.NoError(t, handler.Process(&UserRemoved{Email: email, CompanyID: company}))
|
||||
assert.False(t, handler.IsAllowed(email, company, func(CompanyPrivileges) bool { return true }), "an unordered user removal applies")
|
||||
}
|
||||
|
||||
// snapshotServer serves body as the /authz snapshot at seq; a negative seq sends no header.
|
||||
func snapshotServer(t *testing.T, seq int, body string) *httptest.Server {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if seq >= 0 {
|
||||
w.Header().Set(SequenceHeader, fmt.Sprint(seq))
|
||||
}
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Merges_Snapshot_By_SequenceNo(t *testing.T) {
|
||||
const email = "jim@example.org"
|
||||
admin := func(p CompanyPrivileges) bool { return p.Admin }
|
||||
all := func(CompanyPrivileges) bool { return true }
|
||||
snapshot := `{"jim@example.org": {"in-snapshot": {"admin": true}, "stale-in-snapshot": {"admin": true}}}`
|
||||
|
||||
handler := New(WithBaseURL(snapshotServer(t, 10, snapshot).URL))
|
||||
// Delivered while the snapshot was read.
|
||||
require.NoError(t, handler.Process(&PrivilegeRemoved{Email: email, CompanyID: "in-snapshot", Privilege: PrivilegeAdmin, SequenceNo: 11}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "stale-in-snapshot", Privilege: PrivilegeTime, SequenceNo: 9}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "new", Privilege: PrivilegeAdmin, SequenceNo: 12}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "revoked", Privilege: PrivilegeAdmin, SequenceNo: 8}))
|
||||
require.NoError(t, handler.Process(&UserAdded{Email: email, CompanyID: "revoked-later", SequenceNo: 7}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "revoked-later", Privilege: PrivilegeAdmin, SequenceNo: 13}))
|
||||
|
||||
require.NoError(t, handler.Fetch())
|
||||
|
||||
assert.True(t, handler.IsAllowed(email, "in-snapshot", all))
|
||||
assert.False(t, handler.IsAllowed(email, "in-snapshot", admin), "a removal newer than the snapshot wins")
|
||||
assert.False(t, handler.IsAllowed(email, "stale-in-snapshot", func(p CompanyPrivileges) bool { return p.Time }), "the snapshot replaces an older fact")
|
||||
assert.True(t, handler.IsAllowed(email, "new", admin), "a grant newer than the snapshot is kept")
|
||||
assert.False(t, handler.IsAllowed(email, "revoked", all), "a pair missing from the snapshot is removed")
|
||||
assert.True(t, handler.IsAllowed(email, "revoked-later", admin), "a grant newer than the snapshot survives the removal")
|
||||
|
||||
// Events the snapshot already covers are stale.
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "revoked", Privilege: PrivilegeAdmin, SequenceNo: 10}))
|
||||
assert.False(t, handler.IsAllowed(email, "revoked", all), "an event at the snapshot sequence is stale")
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "revoked", Privilege: PrivilegeAdmin, SequenceNo: 14}))
|
||||
assert.True(t, handler.IsAllowed(email, "revoked", admin))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Replaces_Pairs_Missing_From_Snapshot(t *testing.T) {
|
||||
handler := New(WithBaseURL(snapshotServer(t, 10, `{"jim@example.org": {"kept": {"company": true}}}`).URL))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: "jim@example.org", CompanyID: "gone", Privilege: PrivilegeAdmin, SequenceNo: 3}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: "jane@example.org", CompanyID: "gone", Privilege: PrivilegeAdmin, SequenceNo: 4}))
|
||||
|
||||
require.NoError(t, handler.Fetch())
|
||||
|
||||
assert.Equal(t, map[string]map[string]CompanyPrivileges{"jim@example.org": {"kept": {Company: true}}}, privilegesOf(handler))
|
||||
handler.RLock()
|
||||
defer handler.RUnlock()
|
||||
assert.NotContains(t, handler.state, "jane@example.org", "an email without companies is dropped")
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Without_SequenceHeader_Keeps_Sequenced_Facts(t *testing.T) {
|
||||
handler := New(WithBaseURL(snapshotServer(t, -1, `{"jim@example.org": {"abc-123": {"admin": true}}}`).URL))
|
||||
require.NoError(t, handler.Process(&PrivilegeRemoved{Email: "jim@example.org", CompanyID: "abc-123", Privilege: PrivilegeAdmin, SequenceNo: 3}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: "jim@example.org", CompanyID: "other", Privilege: PrivilegeAdmin, SequenceNo: 4}))
|
||||
|
||||
require.NoError(t, handler.Fetch())
|
||||
|
||||
assert.Equal(t, map[string]map[string]CompanyPrivileges{
|
||||
"jim@example.org": {"abc-123": {}, "other": {Admin: true}},
|
||||
}, privilegesOf(handler))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Rejects_Bad_Responses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
header string
|
||||
err string
|
||||
}{
|
||||
{name: "status", status: http.StatusUnauthorized, err: "fetch privileges: unexpected status 401 Unauthorized"},
|
||||
{name: "invalid header", status: http.StatusOK, header: "abc", err: `fetch privileges: invalid X-Authz-Sequence header "abc"`},
|
||||
{name: "negative header", status: http.StatusOK, header: "-1", err: `fetch privileges: invalid X-Authz-Sequence header "-1"`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tt.header != "" {
|
||||
w.Header().Set(SequenceHeader, tt.header)
|
||||
}
|
||||
w.WriteHeader(tt.status)
|
||||
_, _ = w.Write([]byte(`{"jim@example.org": {"abc-123": {"admin": true}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
handler := New(WithBaseURL(server.URL))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: "jane@example.org", CompanyID: "abc-123", Privilege: PrivilegeTime, SequenceNo: 1}))
|
||||
|
||||
assert.EqualError(t, handler.Fetch(), tt.err)
|
||||
assert.Equal(t, map[string]map[string]CompanyPrivileges{"jane@example.org": {"abc-123": {Time: true}}}, privilegesOf(handler), "state is untouched")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Revocation_During_Fetch_Wins(t *testing.T) {
|
||||
// The consumers are bound before Fetch, so a revocation published after authz-service
|
||||
// read the snapshot is processed while the response is still on its way.
|
||||
handler := New()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// assert, not require: this runs on the server's goroutine.
|
||||
assert.NoError(t, handler.Process(&UserRemoved{Email: "jim@example.org", CompanyID: "abc-123", SequenceNo: 21}))
|
||||
w.Header().Set(SequenceHeader, "20")
|
||||
_, _ = w.Write([]byte(`{"jim@example.org": {"abc-123": {"admin": true}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
handler.baseURL = server.URL
|
||||
|
||||
require.NoError(t, handler.Fetch())
|
||||
|
||||
assert.False(t, handler.IsAllowed("jim@example.org", "abc-123", func(CompanyPrivileges) bool { return true }))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Ignores_Snapshot_Older_Than_Merged_One(t *testing.T) {
|
||||
const email = "jim@example.org"
|
||||
all := func(CompanyPrivileges) bool { return true }
|
||||
handler := New(WithBaseURL(snapshotServer(t, 12, `{}`).URL))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "abc-123", Privilege: PrivilegeAdmin, SequenceNo: 5}))
|
||||
require.NoError(t, handler.Process(&UserRemoved{Email: email, CompanyID: "abc-123", SequenceNo: 11}))
|
||||
require.NoError(t, handler.Fetch())
|
||||
|
||||
// A concurrent Fetch read at seq 10, before the removal, merges last.
|
||||
handler.baseURL = snapshotServer(t, 10, `{"jim@example.org": {"abc-123": {"admin": true}}}`).URL
|
||||
require.NoError(t, handler.Fetch())
|
||||
assert.False(t, handler.IsAllowed(email, "abc-123", all))
|
||||
|
||||
// So does a snapshot without a sequence number.
|
||||
handler.baseURL = snapshotServer(t, -1, `{"jim@example.org": {"abc-123": {"admin": true}}}`).URL
|
||||
require.NoError(t, handler.Fetch())
|
||||
assert.False(t, handler.IsAllowed(email, "abc-123", all))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Unordered_Removal_Holds_Until_Next_Snapshot(t *testing.T) {
|
||||
const email, company = "jim@example.org", "abc-123"
|
||||
all := func(CompanyPrivileges) bool { return true }
|
||||
admin := func(p CompanyPrivileges) bool { return p.Admin }
|
||||
handler := New(WithBaseURL(snapshotServer(t, 30, `{"jim@example.org": {"abc-123": {"admin": true}, "def-456": {"time": true}}}`).URL))
|
||||
require.NoError(t, handler.Process(&UserAdded{Email: email, CompanyID: company, SequenceNo: 10}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: company, Privilege: PrivilegeAdmin, SequenceNo: 11}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "def-456", Privilege: PrivilegeTime, SequenceNo: 12}))
|
||||
|
||||
require.NoError(t, handler.Process(&UserRemoved{Email: email, CompanyID: company}))
|
||||
require.NoError(t, handler.Process(&PrivilegeRemoved{Email: email, CompanyID: "def-456", Privilege: PrivilegeTime}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: company, Privilege: PrivilegeSalary, SequenceNo: 15}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: company, Privilege: PrivilegeAdmin, SequenceNo: 16}))
|
||||
require.NoError(t, handler.Process(&UserAdded{Email: email, CompanyID: company, SequenceNo: 17}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: email, CompanyID: "def-456", Privilege: PrivilegeTime, SequenceNo: 18}))
|
||||
assert.False(t, handler.IsAllowed(email, company, all), "no event overrides an unordered user removal")
|
||||
assert.False(t, handler.IsAllowed(email, "def-456", func(p CompanyPrivileges) bool { return p.Time }), "no event overrides an unordered privilege removal")
|
||||
|
||||
require.NoError(t, handler.Fetch())
|
||||
assert.True(t, handler.IsAllowed(email, company, admin), "the next snapshot replaces the unordered removal")
|
||||
assert.True(t, handler.IsAllowed(email, "def-456", func(p CompanyPrivileges) bool { return p.Time }))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Deletes_Unordered_Removal_Missing_From_Snapshot(t *testing.T) {
|
||||
handler := New(WithBaseURL(snapshotServer(t, 30, `{}`).URL))
|
||||
require.NoError(t, handler.Process(&UserRemoved{Email: "jim@example.org", CompanyID: "abc-123"}))
|
||||
|
||||
require.NoError(t, handler.Fetch())
|
||||
|
||||
handler.RLock()
|
||||
defer handler.RUnlock()
|
||||
assert.Empty(t, handler.state)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Process_Unknown_Privilege_Creates_No_State(t *testing.T) {
|
||||
handler := New()
|
||||
require.NoError(t, handler.Process(&PrivilegeRemoved{Email: "jim@example.org", CompanyID: "abc-123", Privilege: "UNKNOWN", SequenceNo: 1}))
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: "jim@example.org", CompanyID: "abc-123", Privilege: "UNKNOWN", SequenceNo: 2}))
|
||||
|
||||
handler.RLock()
|
||||
defer handler.RUnlock()
|
||||
assert.Empty(t, handler.state)
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Retries_While_ReadView_Not_Ready(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) < 3 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set(SequenceHeader, "7")
|
||||
_, _ = w.Write([]byte(`{"jim@example.org": {"abc-123": {"admin": true}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
handler := New(WithBaseURL(server.URL))
|
||||
handler.retryDelay = time.Millisecond
|
||||
|
||||
require.NoError(t, handler.Fetch())
|
||||
|
||||
assert.Equal(t, int32(3), calls.Load())
|
||||
assert.True(t, handler.IsAllowed("jim@example.org", "abc-123", func(p CompanyPrivileges) bool { return p.Admin }))
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Fetch_Gives_Up_When_ReadView_Stays_Behind(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
handler := New(WithBaseURL(server.URL))
|
||||
handler.retryDelay = time.Millisecond
|
||||
handler.retries = 2
|
||||
|
||||
assert.EqualError(t, handler.Fetch(), "fetch privileges: authz-service read view not ready")
|
||||
assert.Equal(t, int32(3), calls.Load())
|
||||
}
|
||||
|
||||
func TestPrivilegeHandler_Process_Drops_Invalid_SequenceNo(t *testing.T) {
|
||||
for _, seq := range []int{-1, math.MaxInt} {
|
||||
handler := New()
|
||||
require.NoError(t, handler.Process(&PrivilegeAdded{Email: "jim@example.org", CompanyID: "abc-123", Privilege: PrivilegeAdmin, SequenceNo: seq}))
|
||||
require.NoError(t, handler.Process(&UserRemoved{Email: "jim@example.org", CompanyID: "abc-123", SequenceNo: seq}))
|
||||
handler.RLock()
|
||||
assert.Empty(t, handler.state, "seq %d", seq)
|
||||
handler.RUnlock()
|
||||
}
|
||||
}
|
||||
@@ -78,3 +78,7 @@ filter_commits = false
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "oldest"
|
||||
|
||||
[bump]
|
||||
# Before 1.0.0, a breaking change bumps the minor version instead of the major.
|
||||
breaking_always_bump_major = false
|
||||
@@ -1,15 +1,18 @@
|
||||
package client
|
||||
|
||||
// UserAdded is the event sent when a new user is added to a company
|
||||
// UserAdded is the event sent when a new user is added to a company.
|
||||
// SequenceNo is authz-service's global event sequence number; it orders the events.
|
||||
type UserAdded struct {
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
SequenceNo int `json:"sequenceNo"`
|
||||
}
|
||||
|
||||
// UserRemoved is the event sent when a user is removed from a company
|
||||
type UserRemoved struct {
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
SequenceNo int `json:"sequenceNo"`
|
||||
}
|
||||
|
||||
// Privilege is an enumeration of all available privileges
|
||||
@@ -51,14 +54,16 @@ func (e Privilege) String() string {
|
||||
|
||||
// PrivilegeAdded is the event sent when a new privilege is added
|
||||
type PrivilegeAdded struct {
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
Privilege Privilege `json:"privilege"`
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
Privilege Privilege `json:"privilege"`
|
||||
SequenceNo int `json:"sequenceNo"`
|
||||
}
|
||||
|
||||
// PrivilegeRemoved is the event sent when a privilege is removed
|
||||
type PrivilegeRemoved struct {
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
Privilege Privilege `json:"privilege"`
|
||||
Email string `json:"email"`
|
||||
CompanyID string `json:"companyId"`
|
||||
Privilege Privilege `json:"privilege"`
|
||||
SequenceNo int `json:"sequenceNo"`
|
||||
}
|
||||
@@ -1,19 +1,31 @@
|
||||
module gitea.unbound.se/shiny/authz_client
|
||||
|
||||
go 1.22.12
|
||||
|
||||
toolchain go1.25.5
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/sparetimecoders/goamqp v0.3.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
codeberg.org/messaging/go-messaging-amqp v0.0.6
|
||||
codeberg.org/messaging/messaging v0.0.5
|
||||
github.com/stretchr/testify v1.12.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.15.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
@@ -1,20 +1,72 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
codeberg.org/messaging/go-messaging-amqp v0.0.6 h1:TAPcIspjjg44MNXwbcscW+ol1kIX49lnX2N3dsh0e0s=
|
||||
codeberg.org/messaging/go-messaging-amqp v0.0.6/go.mod h1:R4Hg1w964P7OP7vQ9AsXdd4KjgN/YlQ/9+XzXKpEVp4=
|
||||
codeberg.org/messaging/messaging v0.0.5 h1:/ueH90F4RNPUeeJIwdG9oVhDW7+XjCzwOYq8xc0kfQk=
|
||||
codeberg.org/messaging/messaging v0.0.5/go.mod h1:xyWLUcfaVzcN6GWk9uaQsxPVUC2Vm2S89mYZGdiWTWg=
|
||||
codeberg.org/messaging/messaging/tck v0.0.3 h1:a4Nr7ytFqEJloTOyVeAtMNgO9Fig1ZUirjW4FVlK0lc=
|
||||
codeberg.org/messaging/messaging/tck v0.0.3/go.mod h1:RiOsKXGAhNQK2STBVYGjhN0CFtmcrsEne1qUe15n8Q4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/sparetimecoders/goamqp v0.3.3 h1:z/nfTPmrjeU/rIVuNOgsVLCimp3WFoNFvS3ZzXRJ6HE=
|
||||
github.com/sparetimecoders/goamqp v0.3.3/go.mod h1:W9NRCpWLE+Vruv2dcRSbszNil2O826d2Nv6kAkETW5o=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rabbitmq/amqp091-go v1.15.0 h1:LEQL4/yp48/Wigt6A6XOu18RQRo8ZHtB5I/KZJn+gkw=
|
||||
github.com/rabbitmq/amqp091-go v1.15.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -2,5 +2,15 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "OpenTelemetry",
|
||||
"matchPackageNames": [
|
||||
"go.opentelemetry.io/**",
|
||||
"gitea.unbound.se/shiny/otelsetup",
|
||||
"gitea.unbound.se/shiny/logging"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user