SMQ-2724 - Add Auth Callout (#2731)

Signed-off-by: Rodney Osodo <socials@rodneyosodo.com>
This commit is contained in:
b1ackd0t
2025-03-03 21:29:31 +03:00
committed by GitHub
parent fd76a03257
commit 88f7172fb4
15 changed files with 578 additions and 123 deletions
+3 -1
View File
@@ -215,7 +215,9 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) {
errors.Contains(err, apiutil.ErrMissingRoleName),
errors.Contains(err, apiutil.ErrMissingRoleID),
errors.Contains(err, apiutil.ErrMissingPolicyEntityType),
errors.Contains(err, apiutil.ErrMissingRoleMembers):
errors.Contains(err, apiutil.ErrMissingRoleMembers),
errors.Contains(err, apiutil.ErrMissingDescription),
errors.Contains(err, apiutil.ErrMissingEntityID):
err = unwrap(err)
w.WriteHeader(http.StatusBadRequest)
+3
View File
@@ -21,6 +21,9 @@ var (
// ErrMissingID indicates missing entity ID.
ErrMissingID = errors.New("missing entity id")
// ErrMissingEntityID indicates missing entity ID.
ErrMissingEntityID = errors.New("missing entity id")
// ErrMissingClientID indicates missing client ID.
ErrMissingClientID = errors.New("missing cient id")
+81 -67
View File
@@ -59,42 +59,49 @@ Domain consists of the following fields:
The service is configured using the environment variables presented in the following table. Note that any unset variables will be replaced with their default values.
| Variable | Description | Default |
| ------------------------------- | ----------------------------------------------------------------------- | ------------------------------ |
| SMQ_AUTH_LOG_LEVEL | Log level for the Auth service (debug, info, warn, error) | info |
| SMQ_AUTH_DB_HOST | Database host address | localhost |
| SMQ_AUTH_DB_PORT | Database host port | 5432 |
| SMQ_AUTH_DB_USER | Database user | supermq |
| SMQ_AUTH_DB_PASSWORD | Database password | supermq |
| SMQ_AUTH_DB_NAME | Name of the database used by the service | auth |
| SMQ_AUTH_DB_SSL_MODE | Database connection SSL mode (disable, require, verify-ca, verify-full) | disable |
| SMQ_AUTH_DB_SSL_CERT | Path to the PEM encoded certificate file | "" |
| SMQ_AUTH_DB_SSL_KEY | Path to the PEM encoded key file | "" |
| SMQ_AUTH_DB_SSL_ROOT_CERT | Path to the PEM encoded root certificate file | "" |
| SMQ_AUTH_HTTP_HOST | Auth service HTTP host | "" |
| SMQ_AUTH_HTTP_PORT | Auth service HTTP port | 8189 |
| SMQ_AUTH_HTTP_SERVER_CERT | Path to the PEM encoded HTTP server certificate file | "" |
| SMQ_AUTH_HTTP_SERVER_KEY | Path to the PEM encoded HTTP server key file | "" |
| SMQ_AUTH_GRPC_HOST | Auth service gRPC host | "" |
| SMQ_AUTH_GRPC_PORT | Auth service gRPC port | 8181 |
| SMQ_AUTH_GRPC_SERVER_CERT | Path to the PEM encoded gRPC server certificate file | "" |
| SMQ_AUTH_GRPC_SERVER_KEY | Path to the PEM encoded gRPC server key file | "" |
| SMQ_AUTH_GRPC_SERVER_CA_CERTS | Path to the PEM encoded gRPC server CA certificate file | "" |
| SMQ_AUTH_GRPC_CLIENT_CA_CERTS | Path to the PEM encoded gRPC client CA certificate file | "" |
| SMQ_AUTH_SECRET_KEY | String used for signing tokens | secret |
| SMQ_AUTH_ACCESS_TOKEN_DURATION | The access token expiration period | 1h |
| SMQ_AUTH_REFRESH_TOKEN_DURATION | The refresh token expiration period | 24h |
| SMQ_AUTH_INVITATION_DURATION | The invitation token expiration period | 168h |
| SMQ_AUTH_CACHE_URL | Redis URL for caching PAT scopes | redis://localhost:6379/0 |
| SMQ_AUTH_CACHE_KEY_DURATION | Duration for which PAT scope cache keys are valid | 10m |
| SMQ_SPICEDB_HOST | SpiceDB host address | localhost |
| SMQ_SPICEDB_PORT | SpiceDB host port | 50051 |
| SMQ_SPICEDB_PRE_SHARED_KEY | SpiceDB pre-shared key | 12345678 |
| SMQ_SPICEDB_SCHEMA_FILE | Path to SpiceDB schema file | ./docker/spicedb/schema.zed |
| SMQ_JAEGER_URL | Jaeger server URL | <http://jaeger:4318/v1/traces> |
| SMQ_JAEGER_TRACE_RATIO | Jaeger sampling ratio | 1.0 |
| SMQ_SEND_TELEMETRY | Send telemetry to supermq call home server | true |
| SMQ_AUTH_ADAPTER_INSTANCE_ID | Adapter instance ID | "" |
| Variable | Description | Default |
| --------------------------------- | ----------------------------------------------------------------------- | ------------------------------ |
| SMQ_AUTH_LOG_LEVEL | Log level for the Auth service (debug, info, warn, error) | info |
| SMQ_AUTH_DB_HOST | Database host address | localhost |
| SMQ_AUTH_DB_PORT | Database host port | 5432 |
| SMQ_AUTH_DB_USER | Database user | supermq |
| SMQ_AUTH_DB_PASSWORD | Database password | supermq |
| SMQ_AUTH_DB_NAME | Name of the database used by the service | auth |
| SMQ_AUTH_DB_SSL_MODE | Database connection SSL mode (disable, require, verify-ca, verify-full) | disable |
| SMQ_AUTH_DB_SSL_CERT | Path to the PEM encoded certificate file | "" |
| SMQ_AUTH_DB_SSL_KEY | Path to the PEM encoded key file | "" |
| SMQ_AUTH_DB_SSL_ROOT_CERT | Path to the PEM encoded root certificate file | "" |
| SMQ_AUTH_HTTP_HOST | Auth service HTTP host | "" |
| SMQ_AUTH_HTTP_PORT | Auth service HTTP port | 8189 |
| SMQ_AUTH_HTTP_SERVER_CERT | Path to the PEM encoded HTTP server certificate file | "" |
| SMQ_AUTH_HTTP_SERVER_KEY | Path to the PEM encoded HTTP server key file | "" |
| SMQ_AUTH_GRPC_HOST | Auth service gRPC host | "" |
| SMQ_AUTH_GRPC_PORT | Auth service gRPC port | 8181 |
| SMQ_AUTH_GRPC_SERVER_CERT | Path to the PEM encoded gRPC server certificate file | "" |
| SMQ_AUTH_GRPC_SERVER_KEY | Path to the PEM encoded gRPC server key file | "" |
| SMQ_AUTH_GRPC_SERVER_CA_CERTS | Path to the PEM encoded gRPC server CA certificate file | "" |
| SMQ_AUTH_GRPC_CLIENT_CA_CERTS | Path to the PEM encoded gRPC client CA certificate file | "" |
| SMQ_AUTH_SECRET_KEY | String used for signing tokens | secret |
| SMQ_AUTH_ACCESS_TOKEN_DURATION | The access token expiration period | 1h |
| SMQ_AUTH_REFRESH_TOKEN_DURATION | The refresh token expiration period | 24h |
| SMQ_AUTH_INVITATION_DURATION | The invitation token expiration period | 168h |
| SMQ_AUTH_CACHE_URL | Redis URL for caching PAT scopes | redis://localhost:6379/0 |
| SMQ_AUTH_CACHE_KEY_DURATION | Duration for which PAT scope cache keys are valid | 10m |
| SMQ_SPICEDB_HOST | SpiceDB host address | localhost |
| SMQ_SPICEDB_PORT | SpiceDB host port | 50051 |
| SMQ_SPICEDB_PRE_SHARED_KEY | SpiceDB pre-shared key | 12345678 |
| SMQ_SPICEDB_SCHEMA_FILE | Path to SpiceDB schema file | ./docker/spicedb/schema.zed |
| SMQ_JAEGER_URL | Jaeger server URL | <http://jaeger:4318/v1/traces> |
| SMQ_JAEGER_TRACE_RATIO | Jaeger sampling ratio | 1.0 |
| SMQ_SEND_TELEMETRY | Send telemetry to supermq call home server | true |
| SMQ_AUTH_ADAPTER_INSTANCE_ID | Adapter instance ID | "" |
| SMQ_AUTH_CALLOUT_URLS | Comma-separated list of callout URLs | "" |
| SMQ_AUTH_CALLOUT_METHOD | Callout method | POST |
| SMQ_AUTH_CALLOUT_TLS_VERIFICATION | Enable TLS verification for callouts | true |
| SMQ_AUTH_CALLOUT_TIMEOUT | Callout timeout | 10s |
| SMQ_AUTH_CALLOUT_CA_CERT | Path to CA certificate file | "" |
| SMQ_AUTH_CALLOUT_CERT | Path to client certificate file | "" |
| SMQ_AUTH_CALLOUT_KEY | Path to client key file | "" |
## Deployment
@@ -148,6 +155,9 @@ SMQ_JAEGER_URL=http://localhost:14268/api/traces \
SMQ_JAEGER_TRACE_RATIO=1.0 \
SMQ_SEND_TELEMETRY=true \
SMQ_AUTH_ADAPTER_INSTANCE_ID="" \
SMQ_AUTH_CALLOUT_URLS="" \
SMQ_AUTH_CALLOUT_METHOD="POST" \
SMQ_AUTH_CALLOUT_TLS_VERIFICATION=true \
$GOBIN/supermq-auth
```
@@ -171,11 +181,13 @@ PATs in SuperMQ are designed with the following features:
### Token Structure
A PAT consists of three parts separated by underscores:
```
pat_<encoded-user-and-pat-id>_<random-string>
```
Where:
- `pat` is a fixed prefix
- `<encoded-user-and-pat-id>` is a base64-encoded combination of the user ID and PAT ID
- `<random-string>` is a randomly generated string for additional security
@@ -184,31 +196,31 @@ Where:
SuperMQ supports the following operations for PATs:
| Operation | Description |
|-----------|-------------|
| `create` | Create a new resource |
| `read` | Read/view a resource |
| `list` | List resources |
| `update` | Update/modify a resource |
| `delete` | Delete a resource |
| `share` | Share a resource with others |
| `unshare` | Remove sharing permissions |
| `publish` | Publish messages to a channel |
| Operation | Description |
| ----------- | ------------------------------------ |
| `create` | Create a new resource |
| `read` | Read/view a resource |
| `list` | List resources |
| `update` | Update/modify a resource |
| `delete` | Delete a resource |
| `share` | Share a resource with others |
| `unshare` | Remove sharing permissions |
| `publish` | Publish messages to a channel |
| `subscribe` | Subscribe to messages from a channel |
### Entity Types
PATs can be scoped to the following entity types:
| Entity Type | Description |
|-------------|-------------|
| `groups` | User groups |
| `channels` | Communication channels |
| `clients` | Client applications |
| `domains` | Organizational domains |
| `users` | User accounts |
| `dashboards` | Dashboard interfaces |
| `messages` | Message content |
| Entity Type | Description |
| ------------ | ---------------------- |
| `groups` | User groups |
| `channels` | Communication channels |
| `clients` | Client applications |
| `domains` | Organizational domains |
| `users` | User accounts |
| `dashboards` | Dashboard interfaces |
| `messages` | Message content |
### API Examples
@@ -226,6 +238,7 @@ curl --location 'http://localhost:9001/pats' \
```
Response:
```json
{
"id": "a2500226-95dc-4285-87e2-e693e4a0a976",
@@ -333,6 +346,7 @@ This example shows how to create a client in a specific domain (`c16c980a-9d4c-4
When defining scopes for PATs, you can use the wildcard character `*` for the `entity_id` field to grant permissions for all entities of a specific type. This is particularly useful for automation tasks that need to operate on multiple resources.
For example:
- `"entity_id": "*"` - Grants permission for all entities of the specified type
- `"entity_id": "specific-id"` - Grants permission only for the entity with the specified ID
@@ -344,10 +358,10 @@ Using wildcards should be done carefully, as they grant broader permissions. Alw
```json
{
"optional_domain_id": "domain_id",
"entity_type": "clients",
"operation": "create",
"entity_id": "*"
"optional_domain_id": "domain_id",
"entity_type": "clients",
"operation": "create",
"entity_id": "*"
}
```
@@ -357,10 +371,10 @@ This scope allows the PAT to create any client within the specified domain. The
```json
{
"optional_domain_id": "domain_id",
"entity_type": "channels",
"operation": "publish",
"entity_id": "channel_id"
"optional_domain_id": "domain_id",
"entity_type": "channels",
"operation": "publish",
"entity_id": "channel_id"
}
```
@@ -370,10 +384,10 @@ This scope restricts the PAT to only publish to a specific channel (`channel_id`
```json
{
"optional_domain_id": "domain_id",
"entity_type": "dashboards",
"operation": "read",
"entity_id": "*"
"optional_domain_id": "domain_id",
"entity_type": "dashboards",
"operation": "read",
"entity_id": "*"
}
```
+2 -1
View File
@@ -76,8 +76,9 @@ func newService() (auth.Service, *mocks.KeyRepository) {
pService := new(policymocks.Service)
pEvaluator := new(policymocks.Evaluator)
t := jwt.New([]byte(secret))
callback := new(mocks.CallBack)
return auth.New(krepo, pRepo, cache, hash, idProvider, t, pEvaluator, pService, loginDuration, refreshDuration, invalidDuration), krepo
return auth.New(krepo, pRepo, cache, hash, idProvider, t, pEvaluator, pService, loginDuration, refreshDuration, invalidDuration, callback), krepo
}
func newServer(svc auth.Service) *httptest.Server {
+113
View File
@@ -0,0 +1,113 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/absmach/supermq/pkg/errors"
svcerr "github.com/absmach/supermq/pkg/errors/service"
"github.com/absmach/supermq/pkg/policies"
)
type callback struct {
httpClient *http.Client
urls []string
method string
}
// CallBack send auth request to an external service.
//
//go:generate mockery --name CallBack --output=./mocks --filename callback.go --quiet --note "Copyright (c) Abstract Machines"
type CallBack interface {
Authorize(ctx context.Context, pr policies.Policy) error
}
// NewCallback creates a new instance of CallBack.
func NewCallback(httpClient *http.Client, method string, urls []string) (CallBack, error) {
if httpClient == nil {
httpClient = http.DefaultClient
}
if method != http.MethodPost && method != http.MethodGet {
return nil, fmt.Errorf("unsupported auth callback method: %s", method)
}
return &callback{
httpClient: httpClient,
urls: urls,
method: method,
}, nil
}
func (c *callback) Authorize(ctx context.Context, pr policies.Policy) error {
if len(c.urls) == 0 {
return nil
}
payload := map[string]string{
"domain": pr.Domain,
"subject": pr.Subject,
"subject_type": pr.SubjectType,
"subject_kind": pr.SubjectKind,
"subject_relation": pr.SubjectRelation,
"object": pr.Object,
"object_type": pr.ObjectType,
"object_kind": pr.ObjectKind,
"relation": pr.Relation,
"permission": pr.Permission,
}
var err error
// We use a single URL at a time and others as fallbacks
// the first positive result returned by a callback in the chain is considered to be final
for i := range c.urls {
if err = c.makeRequest(ctx, c.urls[i], payload); err == nil {
return nil
}
}
return err
}
func (c *callback) makeRequest(ctx context.Context, urlStr string, params map[string]string) error {
var req *http.Request
var err error
switch c.method {
case http.MethodGet:
query := url.Values{}
for key, value := range params {
query.Set(key, value)
}
req, err = http.NewRequestWithContext(ctx, c.method, urlStr+"?"+query.Encode(), nil)
case http.MethodPost:
data, jsonErr := json.Marshal(params)
if jsonErr != nil {
return jsonErr
}
req, err = http.NewRequestWithContext(ctx, c.method, urlStr, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
}
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.NewSDKErrorWithStatus(svcerr.ErrAuthorization, resp.StatusCode)
}
return nil
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package auth_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/absmach/supermq/auth"
"github.com/absmach/supermq/pkg/errors"
svcerr "github.com/absmach/supermq/pkg/errors/service"
"github.com/absmach/supermq/pkg/policies"
"github.com/stretchr/testify/assert"
)
func TestCallback_Authorize(t *testing.T) {
policy := policies.Policy{
Domain: "test-domain",
Subject: "test-subject",
SubjectType: "user",
SubjectKind: "individual",
SubjectRelation: "owner",
Object: "test-object",
ObjectType: "message",
ObjectKind: "event",
Relation: "publish",
Permission: "allow",
}
cases := []struct {
desc string
method string
respStatus int
expectError bool
}{
{
desc: "successful GET authorization",
method: http.MethodGet,
respStatus: http.StatusOK,
expectError: false,
},
{
desc: "successful POST authorization",
method: http.MethodPost,
respStatus: http.StatusOK,
expectError: false,
},
{
desc: "failed authorization",
method: http.MethodPost,
respStatus: http.StatusForbidden,
expectError: true,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, tc.method, r.Method)
if tc.method == http.MethodGet {
query := r.URL.Query()
assert.Equal(t, policy.Domain, query.Get("domain"))
assert.Equal(t, policy.Subject, query.Get("subject"))
}
w.WriteHeader(tc.respStatus)
}))
defer ts.Close()
cb, err := auth.NewCallback(http.DefaultClient, tc.method, []string{ts.URL})
assert.NoError(t, err)
err = cb.Authorize(context.Background(), policy)
if tc.expectError {
assert.Error(t, err)
assert.True(t, errors.Contains(err, svcerr.ErrAuthorization), "expected authorization error")
} else {
assert.NoError(t, err)
}
})
}
}
func TestCallback_MultipleURLs(t *testing.T) {
ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts1.Close()
ts2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts2.Close()
cb, err := auth.NewCallback(http.DefaultClient, http.MethodPost, []string{ts1.URL, ts2.URL})
assert.NoError(t, err)
err = cb.Authorize(context.Background(), policies.Policy{})
assert.NoError(t, err)
}
func TestCallback_InvalidURL(t *testing.T) {
cb, err := auth.NewCallback(http.DefaultClient, http.MethodPost, []string{"http://invalid-url"})
assert.NoError(t, err)
err = cb.Authorize(context.Background(), policies.Policy{})
assert.Error(t, err)
}
func TestCallback_InvalidMethod(t *testing.T) {
_, err := auth.NewCallback(http.DefaultClient, "invalid-method", []string{"http://example.com"})
assert.Error(t, err)
}
func TestCallback_CancelledContext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
cb, err := auth.NewCallback(http.DefaultClient, http.MethodPost, []string{ts.URL})
assert.NoError(t, err)
err = cb.Authorize(ctx, policies.Policy{})
assert.Error(t, err)
}
func TestNewCallback_NilClient(t *testing.T) {
cb, err := auth.NewCallback(nil, http.MethodPost, []string{"test"})
assert.NoError(t, err)
assert.NotNil(t, cb)
}
func TestCallback_NoURL(t *testing.T) {
cb, err := auth.NewCallback(http.DefaultClient, http.MethodPost, []string{})
assert.NoError(t, err)
err = cb.Authorize(context.Background(), policies.Policy{})
assert.NoError(t, err)
}
+49
View File
@@ -0,0 +1,49 @@
// Code generated by mockery v2.52.3. DO NOT EDIT.
// Copyright (c) Abstract Machines
package mocks
import (
context "context"
policies "github.com/absmach/supermq/pkg/policies"
mock "github.com/stretchr/testify/mock"
)
// CallBack is an autogenerated mock type for the CallBack type
type CallBack struct {
mock.Mock
}
// Authorize provides a mock function with given fields: ctx, pr
func (_m *CallBack) Authorize(ctx context.Context, pr policies.Policy) error {
ret := _m.Called(ctx, pr)
if len(ret) == 0 {
panic("no return value specified for Authorize")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, policies.Policy) error); ok {
r0 = rf(ctx, pr)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewCallBack creates a new instance of CallBack. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewCallBack(t interface {
mock.TestingT
Cleanup(func())
}) *CallBack {
mock := &CallBack{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+4 -2
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
apiutil "github.com/absmach/supermq/api/http/util"
"github.com/absmach/supermq/pkg/errors"
)
@@ -275,15 +276,16 @@ func (s *Scope) Validate() error {
return errInvalidScope
}
if s.EntityID == "" {
return errors.New("missing entityID")
return apiutil.ErrMissingEntityID
}
switch s.EntityType {
case ChannelsType, GroupsType, ClientsType:
if s.OptionalDomainID == "" {
return errors.New("missing domainID")
return apiutil.ErrMissingDomainID
}
}
return nil
}
+9 -2
View File
@@ -108,14 +108,15 @@ type service struct {
loginDuration time.Duration
refreshDuration time.Duration
invitationDuration time.Duration
callback CallBack
}
// New instantiates the auth service implementation.
func New(keys KeyRepository, repo PATSRepository, cache Cache, hasher Hasher, idp supermq.IDProvider, tokenizer Tokenizer, policyEvaluator policies.Evaluator, policyService policies.Service, loginDuration, refreshDuration, invitationDuration time.Duration) Service {
func New(keys KeyRepository, pats PATSRepository, cache Cache, hasher Hasher, idp supermq.IDProvider, tokenizer Tokenizer, policyEvaluator policies.Evaluator, policyService policies.Service, loginDuration, refreshDuration, invitationDuration time.Duration, callback CallBack) Service {
return &service{
tokenizer: tokenizer,
keys: keys,
pats: repo,
pats: pats,
cache: cache,
hasher: hasher,
idProvider: idp,
@@ -124,6 +125,7 @@ func New(keys KeyRepository, repo PATSRepository, cache Cache, hasher Hasher, id
loginDuration: loginDuration,
refreshDuration: refreshDuration,
invitationDuration: invitationDuration,
callback: callback,
}
}
@@ -212,6 +214,11 @@ func (svc service) Authorize(ctx context.Context, pr policies.Policy) error {
if err := svc.checkPolicy(ctx, pr); err != nil {
return err
}
if err := svc.callback.Authorize(ctx, pr); err != nil {
return err
}
return nil
}
+53 -12
View File
@@ -52,6 +52,7 @@ var (
patsrepo *mocks.PATSRepository
cache *mocks.Cache
hasher *mocks.Hasher
callback *mocks.CallBack
)
func newService() (auth.Service, string) {
@@ -62,6 +63,7 @@ func newService() (auth.Service, string) {
patsrepo = new(mocks.PATSRepository)
hasher = new(mocks.Hasher)
idProvider := uuid.NewMock()
callback = new(mocks.CallBack)
t := jwt.New([]byte(secret))
key := auth.Key{
@@ -74,7 +76,7 @@ func newService() (auth.Service, string) {
}
token, _ := t.Issue(key)
return auth.New(krepo, patsrepo, cache, hasher, idProvider, t, pEvaluator, pService, loginDuration, refreshDuration, invalidDuration), token
return auth.New(krepo, patsrepo, cache, hasher, idProvider, t, pEvaluator, pService, loginDuration, refreshDuration, invalidDuration, callback), token
}
func TestIssue(t *testing.T) {
@@ -296,12 +298,14 @@ func TestIssue(t *testing.T) {
repoCall := krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, tc.saveErr)
repoCall1 := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkPolicyRequest).Return(tc.checkPolicyErr)
repoCall2 := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkPlatformPolicyReq).Return(tc.checkPolicyErr1)
repoCall4 := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkDomainPolicyReq).Return(tc.checkPolicyErr)
repoCall3 := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkDomainPolicyReq).Return(tc.checkPolicyErr)
repoCall4 := callback.On("Authorize", mock.Anything, mock.Anything).Return(nil)
_, err := svc.Issue(context.Background(), tc.token, tc.key)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s expected %s got %s\n", tc.desc, tc.err, err))
repoCall.Unset()
repoCall1.Unset()
repoCall2.Unset()
repoCall3.Unset()
repoCall4.Unset()
}
@@ -609,10 +613,12 @@ func TestIssue(t *testing.T) {
for _, tc := range cases4 {
repoCall := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkPlatformAdminReq).Return(tc.checkPlatformAdminErr)
repoCall1 := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkDomainMemberReq).Return(tc.checkDomainMemberErr)
repoCall2 := callback.On("Authorize", mock.Anything, mock.Anything).Return(nil)
_, err := svc.Issue(context.Background(), tc.token, tc.key)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s expected %s got %s\n", tc.desc, tc.err, err))
repoCall.Unset()
repoCall1.Unset()
repoCall2.Unset()
}
}
@@ -744,10 +750,12 @@ func TestIdentify(t *testing.T) {
repocall := krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
repocall1 := pEvaluator.On("CheckPolicy", mock.Anything, mock.Anything).Return(nil)
repoCall2 := callback.On("Authorize", mock.Anything, mock.Anything).Return(nil)
loginSecret, err := svc.Issue(context.Background(), "", auth.Key{Type: auth.AccessKey, User: id, IssuedAt: time.Now(), Domain: groupName})
assert.Nil(t, err, fmt.Sprintf("Issuing login key expected to succeed: %s", err))
repocall.Unset()
repocall1.Unset()
repoCall2.Unset()
repocall2 := krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
recoverySecret, err := svc.Issue(context.Background(), "", auth.Key{Type: auth.RecoveryKey, IssuedAt: time.Now(), Subject: id})
@@ -847,24 +855,28 @@ func TestIdentify(t *testing.T) {
func TestAuthorize(t *testing.T) {
svc, accessToken := newService()
repocall := krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
repocall1 := pEvaluator.On("CheckPolicy", mock.Anything, mock.Anything).Return(nil)
repoCall := krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
repoCall1 := pEvaluator.On("CheckPolicy", mock.Anything, mock.Anything).Return(nil)
repoCall2 := callback.On("Authorize", mock.Anything, mock.Anything).Return(nil)
loginSecret, err := svc.Issue(context.Background(), "", auth.Key{Type: auth.AccessKey, User: id, IssuedAt: time.Now(), Domain: groupName})
assert.Nil(t, err, fmt.Sprintf("Issuing login key expected to succeed: %s", err))
repocall.Unset()
repocall1.Unset()
saveCall := krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
repoCall.Unset()
repoCall1.Unset()
repoCall2.Unset()
repoCall = krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
exp1 := time.Now().Add(-2 * time.Second)
expSecret, err := svc.Issue(context.Background(), loginSecret.AccessToken, auth.Key{Type: auth.APIKey, IssuedAt: time.Now(), ExpiresAt: exp1})
assert.Nil(t, err, fmt.Sprintf("Issuing expired login key expected to succeed: %s", err))
saveCall.Unset()
repoCall.Unset()
repocall2 := krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
repocall3 := pEvaluator.On("CheckPolicy", mock.Anything, mock.Anything).Return(nil)
repoCall = krepo.On("Save", mock.Anything, mock.Anything).Return(mock.Anything, nil)
repoCall1 = pEvaluator.On("CheckPolicy", mock.Anything, mock.Anything).Return(nil)
repoCall2 = callback.On("Authorize", mock.Anything, mock.Anything).Return(nil)
emptySubject, err := svc.Issue(context.Background(), "", auth.Key{Type: auth.AccessKey, User: "", IssuedAt: time.Now(), Domain: groupName})
assert.Nil(t, err, fmt.Sprintf("Issuing login key expected to succeed: %s", err))
repocall2.Unset()
repocall3.Unset()
repoCall.Unset()
repoCall1.Unset()
te := jwt.New([]byte(secret))
key := auth.Key{
@@ -881,6 +893,7 @@ func TestAuthorize(t *testing.T) {
policyReq policies.Policy
checkDomainPolicyReq policies.Policy
checkPolicyReq policies.Policy
callBackErr error
checkPolicyErr error
checkDomainPolicyErr error
err error
@@ -1110,16 +1123,44 @@ func TestAuthorize(t *testing.T) {
},
err: svcerr.ErrDomainAuthorization,
},
{
desc: "failed to authorize a user via callback",
policyReq: policies.Policy{
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Object: policies.SuperMQObject,
ObjectType: policies.PlatformType,
Permission: policies.AdminPermission,
},
checkPolicyReq: policies.Policy{
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Object: policies.SuperMQObject,
ObjectType: policies.PlatformType,
Permission: policies.AdminPermission,
},
checkDomainPolicyReq: policies.Policy{
Subject: id,
SubjectType: policies.UserType,
Object: validID,
ObjectType: policies.DomainType,
Permission: policies.MembershipPermission,
},
callBackErr: svcerr.ErrAuthorization,
err: svcerr.ErrAuthorization,
},
}
for _, tc := range cases {
policyCall := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkPolicyReq).Return(tc.checkPolicyErr)
policyCall1 := pEvaluator.On("CheckPolicy", mock.Anything, tc.checkDomainPolicyReq).Return(tc.checkDomainPolicyErr)
repoCall := krepo.On("Remove", mock.Anything, mock.Anything, mock.Anything).Return(nil)
callbackCall := callback.On("Authorize", mock.Anything, tc.checkPolicyReq).Return(tc.callBackErr)
err := svc.Authorize(context.Background(), tc.policyReq)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s expected %s got %s\n", tc.desc, tc.err, err))
policyCall.Unset()
policyCall1.Unset()
repoCall.Unset()
callbackCall.Unset()
}
}
+70 -20
View File
@@ -5,9 +5,13 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"time"
@@ -60,22 +64,29 @@ const (
)
type config struct {
LogLevel string `env:"SMQ_AUTH_LOG_LEVEL" envDefault:"info"`
SecretKey string `env:"SMQ_AUTH_SECRET_KEY" envDefault:"secret"`
JaegerURL url.URL `env:"SMQ_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"SMQ_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"SMQ_AUTH_ADAPTER_INSTANCE_ID" envDefault:""`
AccessDuration time.Duration `env:"SMQ_AUTH_ACCESS_TOKEN_DURATION" envDefault:"1h"`
RefreshDuration time.Duration `env:"SMQ_AUTH_REFRESH_TOKEN_DURATION" envDefault:"24h"`
InvitationDuration time.Duration `env:"SMQ_AUTH_INVITATION_DURATION" envDefault:"168h"`
SpicedbHost string `env:"SMQ_SPICEDB_HOST" envDefault:"localhost"`
SpicedbPort string `env:"SMQ_SPICEDB_PORT" envDefault:"50051"`
SpicedbSchemaFile string `env:"SMQ_SPICEDB_SCHEMA_FILE" envDefault:"./docker/spicedb/schema.zed"`
SpicedbPreSharedKey string `env:"SMQ_SPICEDB_PRE_SHARED_KEY" envDefault:"12345678"`
TraceRatio float64 `env:"SMQ_JAEGER_TRACE_RATIO" envDefault:"1.0"`
ESURL string `env:"SMQ_ES_URL" envDefault:"nats://localhost:4222"`
CacheURL string `env:"SMQ_AUTH_CACHE_URL" envDefault:"redis://localhost:6379/0"`
CacheKeyDuration time.Duration `env:"SMQ_AUTH_CACHE_KEY_DURATION" envDefault:"10m"`
LogLevel string `env:"SMQ_AUTH_LOG_LEVEL" envDefault:"info"`
SecretKey string `env:"SMQ_AUTH_SECRET_KEY" envDefault:"secret"`
JaegerURL url.URL `env:"SMQ_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"SMQ_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"SMQ_AUTH_ADAPTER_INSTANCE_ID" envDefault:""`
AccessDuration time.Duration `env:"SMQ_AUTH_ACCESS_TOKEN_DURATION" envDefault:"1h"`
RefreshDuration time.Duration `env:"SMQ_AUTH_REFRESH_TOKEN_DURATION" envDefault:"24h"`
InvitationDuration time.Duration `env:"SMQ_AUTH_INVITATION_DURATION" envDefault:"168h"`
SpicedbHost string `env:"SMQ_SPICEDB_HOST" envDefault:"localhost"`
SpicedbPort string `env:"SMQ_SPICEDB_PORT" envDefault:"50051"`
SpicedbSchemaFile string `env:"SMQ_SPICEDB_SCHEMA_FILE" envDefault:"./docker/spicedb/schema.zed"`
SpicedbPreSharedKey string `env:"SMQ_SPICEDB_PRE_SHARED_KEY" envDefault:"12345678"`
TraceRatio float64 `env:"SMQ_JAEGER_TRACE_RATIO" envDefault:"1.0"`
ESURL string `env:"SMQ_ES_URL" envDefault:"nats://localhost:4222"`
CacheURL string `env:"SMQ_AUTH_CACHE_URL" envDefault:"redis://localhost:6379/0"`
CacheKeyDuration time.Duration `env:"SMQ_AUTH_CACHE_KEY_DURATION" envDefault:"10m"`
AuthCalloutURLs []string `env:"SMQ_AUTH_CALLOUT_URLS" envDefault:"" envSeparator:","`
AuthCalloutMethod string `env:"SMQ_AUTH_CALLOUT_METHOD" envDefault:"POST"`
AuthCalloutTLSVerification bool `env:"SMQ_AUTH_CALLOUT_TLS_VERIFICATION" envDefault:"true"`
AuthCalloutTimeout time.Duration `env:"SMQ_AUTH_CALLOUT_TIMEOUT" envDefault:"10s"`
AuthCalloutCACert string `env:"SMQ_AUTH_CALLOUT_CA_CERT" envDefault:""`
AuthCalloutCert string `env:"SMQ_AUTH_CALLOUT_CERT" envDefault:""`
AuthCalloutKey string `env:"SMQ_AUTH_CALLOUT_KEY" envDefault:""`
}
func main() {
@@ -145,7 +156,12 @@ func main() {
return
}
svc := newService(ctx, db, tracer, cfg, dbConfig, logger, spicedbclient, cacheclient, cfg.CacheKeyDuration)
svc, err := newService(db, tracer, cfg, dbConfig, logger, spicedbclient, cacheclient, cfg.CacheKeyDuration)
if err != nil {
logger.Error(fmt.Sprintf("failed to create service : %s\n", err.Error()))
exitCode = 1
return
}
grpcServerConfig := server.Config{Port: defSvcGRPCPort}
if err := env.ParseWithOptions(&grpcServerConfig, env.Options{Prefix: envPrefixGrpc}); err != nil {
@@ -225,7 +241,7 @@ func initSchema(ctx context.Context, client *authzed.ClientWithExperimental, sch
return nil
}
func newService(_ context.Context, db *sqlx.DB, tracer trace.Tracer, cfg config, dbConfig pgclient.Config, logger *slog.Logger, spicedbClient *authzed.ClientWithExperimental, cacheClient *redis.Client, keyDuration time.Duration) auth.Service {
func newService(db *sqlx.DB, tracer trace.Tracer, cfg config, dbConfig pgclient.Config, logger *slog.Logger, spicedbClient *authzed.ClientWithExperimental, cacheClient *redis.Client, keyDuration time.Duration) (auth.Service, error) {
cache := cache.NewPatsCache(cacheClient, keyDuration)
database := pgclient.NewDatabase(db, dbConfig, tracer)
@@ -239,11 +255,45 @@ func newService(_ context.Context, db *sqlx.DB, tracer trace.Tracer, cfg config,
t := jwt.New([]byte(cfg.SecretKey))
svc := auth.New(keysRepo, patsRepo, nil, hasher, idProvider, t, pEvaluator, pService, cfg.AccessDuration, cfg.RefreshDuration, cfg.InvitationDuration)
tlsConfig := &tls.Config{
InsecureSkipVerify: !cfg.AuthCalloutTLSVerification,
}
if cfg.AuthCalloutCert != "" || cfg.AuthCalloutKey != "" {
clientTLSCert, err := tls.LoadX509KeyPair(cfg.AuthCalloutCert, cfg.AuthCalloutKey)
if err != nil {
return nil, err
}
certPool, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
caCert, err := os.ReadFile(cfg.AuthCalloutCACert)
if err != nil {
return nil, err
}
if !certPool.AppendCertsFromPEM(caCert) {
return nil, errors.New("failed to append CA certificate")
}
tlsConfig.RootCAs = certPool
tlsConfig.Certificates = []tls.Certificate{clientTLSCert}
}
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
Timeout: cfg.AuthCalloutTimeout,
}
callback, err := auth.NewCallback(httpClient, cfg.AuthCalloutMethod, cfg.AuthCalloutURLs)
if err != nil {
return nil, err
}
svc := auth.New(keysRepo, patsRepo, nil, hasher, idProvider, t, pEvaluator, pService, cfg.AccessDuration, cfg.RefreshDuration, cfg.InvitationDuration, callback)
svc = api.LoggingMiddleware(svc, logger)
counter, latency := prometheus.MakeMetrics("auth", "api")
svc = api.MetricsMiddleware(svc, counter, latency)
svc = tracing.New(svc, tracer)
return svc
return svc, nil
}
+7
View File
@@ -101,6 +101,13 @@ SMQ_AUTH_INVITATION_DURATION="168h"
SMQ_AUTH_ADAPTER_INSTANCE_ID=
SMQ_AUTH_CACHE_URL=redis://auth-redis:${SMQ_REDIS_TCP_PORT}/0
SMQ_AUTH_CACHE_KEY_DURATION=10m
SMQ_AUTH_CALLOUT_URLS=""
SMQ_AUTH_CALLOUT_METHOD="POST"
SMQ_AUTH_CALLOUT_TLS_VERIFICATION="false"
SMQ_AUTH_CALLOUT_TIMEOUT="10s"
SMQ_AUTH_CALLOUT_CA_CERT=""
SMQ_AUTH_CALLOUT_CERT=""
SMQ_AUTH_CALLOUT_KEY=""
#### Auth Client Config
SMQ_AUTH_URL=auth:9001
+23
View File
@@ -142,6 +142,13 @@ services:
SMQ_ES_URL: ${SMQ_ES_URL}
SMQ_AUTH_CACHE_URL: ${SMQ_AUTH_CACHE_URL}
SMQ_AUTH_CACHE_KEY_DURATION: ${SMQ_AUTH_CACHE_KEY_DURATION}
SMQ_AUTH_CALLOUT_URLS: ${SMQ_AUTH_CALLOUT_URLS}
SMQ_AUTH_CALLOUT_METHOD: ${SMQ_AUTH_CALLOUT_METHOD}
SMQ_AUTH_CALLOUT_TLS_VERIFICATION: ${SMQ_AUTH_CALLOUT_TLS_VERIFICATION}
SMQ_AUTH_CALLOUT_TIMEOUT: ${SMQ_AUTH_CALLOUT_TIMEOUT}
SMQ_AUTH_CALLOUT_CA_CERT: ${SMQ_AUTH_CALLOUT_CA_CERT}
SMQ_AUTH_CALLOUT_CERT: ${SMQ_AUTH_CALLOUT_CERT}
SMQ_AUTH_CALLOUT_KEY: ${SMQ_AUTH_CALLOUT_KEY}
ports:
- ${SMQ_AUTH_HTTP_PORT}:${SMQ_AUTH_HTTP_PORT}
- ${SMQ_AUTH_GRPC_PORT}:${SMQ_AUTH_GRPC_PORT}
@@ -171,6 +178,22 @@ services:
target: /auth-grpc-client-ca${SMQ_AUTH_GRPC_CLIENT_CA_CERTS:+.crt}
bind:
create_host_path: true
# Auth Callout Client Certificates
- type: bind
source: ${SMQ_AUTH_CALLOUT_CLIENT_CERT:-ssl/certs/dummy/client_cert}
target: /auth-callout-client${SMQ_AUTH_CALLOUT_CLIENT_CERT:+.crt}
bind:
create_host_path: true
- type: bind
source: ${SMQ_AUTH_CALLOUT_CLIENT_KEY:-ssl/certs/dummy/client_key}
target: /auth-callout-client${SMQ_AUTH_CALLOUT_CLIENT_KEY:+.key}
bind:
create_host_path: true
- type: bind
source: ${SMQ_AUTH_CALLOUT_CLIENT_CA_CERTS:-ssl/certs/dummy/client_ca_certs}
target: /auth-callout-client-ca${SMQ_AUTH_CALLOUT_CLIENT_CA_CERTS:+.crt}
bind:
create_host_path: true
domains-db:
image: postgres:16.2-alpine
+6 -6
View File
@@ -5,11 +5,11 @@ go 1.23.4
require (
github.com/0x6flab/namegenerator v1.4.0
github.com/absmach/callhome v0.14.0
github.com/absmach/certs v0.0.0-20250127084046-fb0da0712b2b
github.com/absmach/certs v0.0.0-20250226124728-fa26b7d3aa28
github.com/absmach/mgate v0.4.5
github.com/absmach/senml v1.0.6
github.com/authzed/authzed-go v1.3.0
github.com/authzed/grpcutil v0.0.0-20240123194739-2ea1e3d2d98b
github.com/authzed/grpcutil v0.0.0-20250221190651-1985b19b35b8
github.com/authzed/spicedb v1.40.1
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v4 v4.3.0
@@ -51,7 +51,7 @@ require (
golang.org/x/crypto v0.35.0
golang.org/x/oauth2 v0.27.0
golang.org/x/sync v0.11.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e
google.golang.org/grpc v1.70.0
google.golang.org/protobuf v1.36.5
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
@@ -112,7 +112,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/jzelinskie/stringz v0.0.3 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
@@ -155,12 +155,12 @@ require (
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.10.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250207221924-e9438ea467c6 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250227231956-55c901821b1e // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+12 -12
View File
@@ -19,8 +19,8 @@ github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrd
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
github.com/absmach/callhome v0.14.0 h1:zB4tIZJ1YUmZ1VGHFPfMA/Lo6/Mv19y2dvoOiXj2BWs=
github.com/absmach/callhome v0.14.0/go.mod h1:l12UJOfibK4Muvg/AbupHuquNV9qSz/ROdTEPg7f2Vk=
github.com/absmach/certs v0.0.0-20250127084046-fb0da0712b2b h1:EGIqL1bARjRSS7kH98Q5O/g7lZN/Q0KtAVX5mxRcq84=
github.com/absmach/certs v0.0.0-20250127084046-fb0da0712b2b/go.mod h1:g6Kqge7RVxwt+LRxqt+09cqa2SgPAwXvIPoyPsEqZlQ=
github.com/absmach/certs v0.0.0-20250226124728-fa26b7d3aa28 h1:CTSj7kbihYXfMg9oLGYdrK4axYAdclvKVrySPIK5jFk=
github.com/absmach/certs v0.0.0-20250226124728-fa26b7d3aa28/go.mod h1:ZOChAukRsoylcCNyeKpdXNeRIlfmyfO5L6I9thHQKKo=
github.com/absmach/mgate v0.4.5 h1:l6RmrEsR9jxkdb9WHUSecmT0HA41TkZZQVffFfUAIfI=
github.com/absmach/mgate v0.4.5/go.mod h1:IvRIHZexZPEIAPmmaJF0L5DY2ERjj+GxRGitOW4s6qo=
github.com/absmach/senml v1.0.6 h1:WPeIl6vQ00k7ghWSZYT/QP0KUxq2+4zQoaC7240pLFk=
@@ -31,8 +31,8 @@ github.com/authzed/authzed-go v1.3.0 h1:jKIMpYDy+6WoOwl32HRURxLZxNGm+I7ObUlTntEP
github.com/authzed/authzed-go v1.3.0/go.mod h1:MYkXImtFAxrM/bVZvmC/WO+gZC9RLlvpCM51SLaUZb0=
github.com/authzed/cel-go v0.20.2 h1:GlmLecGry7Z8HU0k+hmaHHUV05ZHrsFxduXHtIePvck=
github.com/authzed/cel-go v0.20.2/go.mod h1:pJHVFWbqUHV1J+klQoZubdKswlbxcsbojda3mye9kiU=
github.com/authzed/grpcutil v0.0.0-20240123194739-2ea1e3d2d98b h1:wbh8IK+aMLTCey9sZasO7b6BWLAJnHHvb79fvWCXwxw=
github.com/authzed/grpcutil v0.0.0-20240123194739-2ea1e3d2d98b/go.mod h1:s3qC7V7XIbiNWERv7Lfljy/Lx25/V1Qlexb0WJuA8uQ=
github.com/authzed/grpcutil v0.0.0-20250221190651-1985b19b35b8 h1:y17oq4U8n+k1OcIGGDsjYdIdp4QywGcE7ZphIvtfEbo=
github.com/authzed/grpcutil v0.0.0-20250221190651-1985b19b35b8/go.mod h1:Pf1ZSi41EePvx1GC1DeEJw5dn35iUcxZHqpHuG1Rpic=
github.com/authzed/spicedb v1.40.1 h1:Ka9424FJvnYvfnWzN5aK3q/1xnDEXf5fBAwSHKaPHBc=
github.com/authzed/spicedb v1.40.1/go.mod h1:W/BC/b7hM+yJRp8T2W47kPE/L/ymqQ6w54wcuA7aB9M=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -250,8 +250,8 @@ github.com/jzelinskie/stringz v0.0.3 h1:0GhG3lVMYrYtIvRbxvQI6zqRTT1P1xyQlpa0FhfU
github.com/jzelinskie/stringz v0.0.3/go.mod h1:hHYbgxJuNLRw91CmpuFsYEOyQqpDVFg8pvEh23vy4P0=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -481,8 +481,8 @@ golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZP
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -595,10 +595,10 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto/googleapis/api v0.0.0-20250207221924-e9438ea467c6 h1:L9JNMl/plZH9wmzQUHleO/ZZDSN+9Gh41wPczNy+5Fk=
google.golang.org/genproto/googleapis/api v0.0.0-20250207221924-e9438ea467c6/go.mod h1:iYONQfRdizDB8JJBybql13nArx91jcUk7zCXEsOofM4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6 h1:2duwAxN2+k0xLNpjnHTXoMUgnv6VPSp5fiqTuwSxjmI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250207221924-e9438ea467c6/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/genproto/googleapis/api v0.0.0-20250227231956-55c901821b1e h1:nsxey/MfoGzYNduN0NN/+hqP9iiCIYsrVbXb/8hjFM8=
google.golang.org/genproto/googleapis/api v0.0.0-20250227231956-55c901821b1e/go.mod h1:Xsh8gBVxGCcbV8ZeTB9wI5XPyZ5RvC6V3CTeeplHbiA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e h1:YA5lmSs3zc/5w+xsRcHqpETkaYyK63ivEPzNTcUUlSA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=