Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Test

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.5"

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Check formatting
run: |
unformatted=$(gofmt -l . | grep -v '^internal/genpb/' || true)
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"; echo "$unformatted"; exit 1
fi

# make test starts the quay.io/authorizer/authorizer:2.3.0 container
# (docker is preinstalled on ubuntu-latest), runs the integration suite
# across graphql/rest/grpc, then tears the container down.
- name: Integration tests
run: make test
13 changes: 8 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
# 2. Run tests: make test

# Docker image for authorizer server
AUTHORIZER_IMAGE := lakhansamani/authorizer:latest
AUTHORIZER_IMAGE := quay.io/authorizer/authorizer:2.3.0
AUTHORIZER_CONTAINER := authorizer-test

.PHONY: docker-up docker-down test

# Start authorizer in Docker for integration testing
# Start authorizer in Docker for integration testing. gRPC listens on its own
# port (9091) and needs --grpc-insecure for plaintext local/CI testing.
docker-up:
@if docker ps -q -f name=^/$(AUTHORIZER_CONTAINER)$$ | grep -q .; then \
echo "Authorizer container already running"; \
Expand All @@ -19,17 +20,19 @@ docker-up:
docker run -d --rm \
--name $(AUTHORIZER_CONTAINER) \
-p 8080:8080 \
-p 9091:9091 \
$(AUTHORIZER_IMAGE) \
--database-type=sqlite \
--database-url=test.db \
--jwt-type=HS256 \
--jwt-secret=test \
--admin-secret=admin \
--client-id=123456 \
--client-secret=secret; \
--client-secret=secret \
--grpc-insecure=true; \
echo "Waiting for authorizer to be ready..."; \
sleep 3; \
echo "Authorizer is running at http://localhost:8080"; \
sleep 5; \
echo "Authorizer is running at http://localhost:8080 (gRPC :9091)"; \
fi

# Stop the authorizer container
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ if err != nil {
}
```

> **Note (Authorizer ≥ v2.3.0):** the server's CSRF guard requires an `Origin`
> header on state-changing requests. The client sends the Authorizer server's
> own origin by default, which always passes. If your instance restricts
> `ALLOWED_ORIGINS`, pass your app's origin instead via `extraHeaders`:
> `map[string]string{"Origin": "https://your-app.com"}`.

### Step 3: Access all the SDK methods using authorizer client instance, initialized on step 2

**Example**
Expand Down Expand Up @@ -98,6 +104,9 @@ for _, r := range res.Results {

**2. List accessible objects** — `ListPermissions` returns the ids of every object of a
type the caller holds a relation on (handy for filtering a list to what the user can see).
Both filters are optional: an empty request enumerates everything the caller holds, with
the `(Object, Relation)` detail in `Permissions` and `Truncated` set when the result was
capped at 1000 entries.

```go
res, err := authorizerClient.ListPermissions(&authorizer.ListPermissionsRequest{
Expand Down
180 changes: 180 additions & 0 deletions admin_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package authorizer

import (
"context"
"encoding/json"
"fmt"
"strings"

authorizerv1 "github.com/authorizerdev/authorizer-go/internal/genpb/authorizer/v1"
)

// adminSecretHeader is the header (and gRPC metadata key) carrying the admin
// secret on every admin call.
const adminSecretHeader = "x-authorizer-admin-secret"

// AuthorizerAdminClient is the admin surface of the SDK. It mirrors the
// AuthorizerAdminService proto and carries the admin secret plus the selected
// wire protocol. The request/response types are the generated proto messages
// (authorizerv1.*), the canonical signatures shared across all three SDKs.
type AuthorizerAdminClient struct {
AuthorizerURL string
AdminSecret string
ExtraHeaders map[string]string
// Protocol selects the wire transport. Defaults to ProtocolGraphQL.
Protocol Protocol
// GRPCEndpoint overrides the host:port dialed when Protocol is grpc. When
// empty it is derived from AuthorizerURL using the gRPC default port.
GRPCEndpoint string
}

// AdminClientOption customizes an AuthorizerAdminClient at construction time.
type AdminClientOption func(*AuthorizerAdminClient)

// WithAdminProtocol sets the wire transport the admin client uses.
func WithAdminProtocol(p Protocol) AdminClientOption {
return func(c *AuthorizerAdminClient) {
c.Protocol = p
}
}

// WithAdminGRPCEndpoint sets the host:port dialed for grpc calls. The authorizer
// server's gRPC listener runs on its own port (default 9091), separate from the
// HTTP port in AuthorizerURL. When unset, the endpoint is derived from
// AuthorizerURL's host with the default gRPC port (9091).
func WithAdminGRPCEndpoint(addr string) AdminClientOption {
return func(c *AuthorizerAdminClient) {
c.GRPCEndpoint = addr
}
}

// WithAdminExtraHeaders sets default headers added to every admin HTTP request.
func WithAdminExtraHeaders(headers map[string]string) AdminClientOption {
return func(c *AuthorizerAdminClient) {
c.ExtraHeaders = headers
}
}

// NewAuthorizerAdminClient creates an admin client instance authenticated with
// the admin secret. Default protocol is graphql; override with WithAdminProtocol.
func NewAuthorizerAdminClient(authorizerURL, adminSecret string, opts ...AdminClientOption) (*AuthorizerAdminClient, error) {
if strings.TrimSpace(authorizerURL) == "" {
return nil, fmt.Errorf("authorizerURL missing")
}
if strings.TrimSpace(adminSecret) == "" {
return nil, fmt.Errorf("adminSecret missing")
}

c := &AuthorizerAdminClient{
AuthorizerURL: strings.TrimSuffix(authorizerURL, "/"),
AdminSecret: adminSecret,
ExtraHeaders: map[string]string{},
Protocol: ProtocolGraphQL,
}
for _, opt := range opts {
opt(c)
}
return c, nil
}

// adminMethodSpec declares how one admin method is carried over each protocol.
// A protocol whose field is empty/nil is unsupported and yields a clear error.
type adminMethodSpec struct {
name string // human method name for error messages, e.g. "AdminMeta"

// graphql is the prebuilt GraphQL request; nil means gql-unsupported.
graphql *GraphQLRequest
// graphqlField is the top-level response field to unwrap (the _-prefixed op).
graphqlField string

// restMethod / restPath; empty restPath means rest-unsupported.
restMethod string
restPath string
restBody interface{}

// grpcCall invokes the admin stub; nil means grpc-unsupported.
grpcCall func(ctx context.Context, cli authorizerv1.AuthorizerAdminServiceClient) (interface{}, error)
}

// adminAuthHeaders merges the admin-secret header onto the client defaults.
func (c *AuthorizerAdminClient) adminAuthHeaders() map[string]string {
h := mergeHeaders(c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret})
return h
}

// supported reports which protocols a spec implements, for error messages.
func (s adminMethodSpec) supported() string {
var got []string
if s.graphql != nil {
got = append(got, "graphql")
}
if s.restPath != "" {
got = append(got, "rest")
}
if s.grpcCall != nil {
got = append(got, "grpc")
}
return strings.Join(got, " or ")
}

// execute dispatches an admin method over the client's selected protocol and
// unmarshals the result into out (a pointer). Calling a method on a protocol it
// does not support returns a clear error before any network call.
func (c *AuthorizerAdminClient) execute(spec adminMethodSpec, out interface{}) error {
switch c.Protocol {
case ProtocolREST:
if spec.restPath == "" {
return unsupportedProtocol(spec.name, c.Protocol, spec.supported())
}
return doREST(c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, out)

case ProtocolGRPC:
if spec.grpcCall == nil {
return unsupportedProtocol(spec.name, c.Protocol, spec.supported())
}
conn, err := grpcDial(c.AuthorizerURL, c.GRPCEndpoint)
if err != nil {
return err
}
defer conn.Close()

ctx := outgoingContext(context.Background(), c.adminAuthHeaders())
cli := authorizerv1.NewAuthorizerAdminServiceClient(conn)
resp, err := spec.grpcCall(ctx, cli)
if err != nil {
return err
}
return remarshal(resp, out)

default: // ProtocolGraphQL
if spec.graphql == nil {
return unsupportedProtocol(spec.name, c.Protocol, spec.supported())
}
bytesData, err := c.executeGraphQL(spec.graphql)
if err != nil {
return err
}
if out == nil {
return nil
}
var res map[string]json.RawMessage
if err := json.Unmarshal(bytesData, &res); err != nil {
return err
}
field, ok := res[spec.graphqlField]
if !ok {
return nil
}
return json.Unmarshal(field, out)
}
}

// executeGraphQL runs an admin GraphQL request, attaching the admin-secret
// header and the CSRF Origin header (reusing the user client's transport).
func (c *AuthorizerAdminClient) executeGraphQL(req *GraphQLRequest) ([]byte, error) {
uc := &AuthorizerClient{
AuthorizerURL: c.AuthorizerURL,
ExtraHeaders: c.ExtraHeaders,
}
return uc.ExecuteGraphQL(req, map[string]string{adminSecretHeader: c.AdminSecret})
}
Loading
Loading