diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0f76c65 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + go-deps: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..633ff6b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - run: go vet ./... + - name: Run tests with coverage + run: go test -race -count=1 -covermode=atomic -coverprofile=coverage.out -v ./... + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -n 1 + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + with: + version: v2.12.2 + + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - name: Run end-to-end MCP smoke test (uses Python fake PDP) + run: make smoke + + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - name: govulncheck (Go module CVE scan) + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + - name: osv-scanner (OSV.dev cross-ecosystem scan) + uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + scan-args: |- + --recursive + ./ + + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - name: Lint GitHub Actions workflows + uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b90150f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + # Required for cosign keyless signing via GitHub OIDC + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + + - name: Install syft + uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..bd8e97b --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,57 @@ +version: 2 + +project_name: mcp-authzen + +before: + hooks: + # `verify` checks checksums without mutating go.mod / go.sum, so a + # release stays bit-reproducible. CI / dev should run `go mod tidy`. + - go mod verify + +builds: + - id: mcp-authzen + binary: mcp-authzen + main: ./ + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - format_overrides: + - goos: windows + formats: [zip] + formats: + - tar.gz + name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" + files: + - LICENSE + - README.md + +checksum: + name_template: "{{ .ProjectName }}-{{ .Version }}-checksums.txt" + +release: + prerelease: auto + +sboms: + - artifacts: archive + +signs: + - cmd: cosign + certificate: "${artifact}.pem" + args: + - "sign-blob" + - "--output-certificate=${certificate}" + - "--output-signature=${signature}" + - "${artifact}" + - "--yes" + artifacts: checksum + output: true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c8e2af --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +BIN := mcp-authzen +PKG := ./... + +.PHONY: build test vet lint smoke clean fmt tidy + +build: + go build -trimpath -ldflags "-s -w" -o $(BIN) . + +test: + go test -race -count=1 -v $(PKG) + +vet: + go vet $(PKG) + +lint: + golangci-lint run $(PKG) + +smoke: build + ./scripts/smoke.sh ./$(BIN) + +fmt: + gofmt -s -w . + +tidy: + go mod tidy + +clean: + rm -f $(BIN) + rm -rf dist/ diff --git a/README.md b/README.md index 7a7a2a6..a58525c 100644 --- a/README.md +++ b/README.md @@ -2,46 +2,38 @@ [![ci](https://github.com/0-draft/mcp-authzen/actions/workflows/ci.yml/badge.svg)](https://github.com/0-draft/mcp-authzen/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/0-draft/mcp-authzen.svg)](https://pkg.go.dev/github.com/0-draft/mcp-authzen) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -A [Model Context Protocol](https://modelcontextprotocol.io) server that fronts an [OpenID AuthZEN 1.0](https://openid.net/specs/authorization-api-1_0.html) Policy Decision Point (PDP). +[MCP](https://modelcontextprotocol.io) server that fronts an [OpenID AuthZEN 1.0](https://openid.net/specs/authorization-api-1_0.html) PDP. -Lets an LLM agent ask **"can subject S perform action A on resource R?"** and have the answer come from a real authorization decision point — your OPA-AuthZEN, your Topaz, your in-house PDP — instead of from the model's training data. +Sends a `subject + resource + action + context` bundle to a real Policy Decision Point (OPA-AuthZEN, Topaz, your own) and returns the decision. Use it when "can alice delete this?" should be answered by policy code, not by the model's guess. -## Tool +Conforms to AuthZEN 1.0 §6 (single-evaluation request/response) and §5.5 (decision entity). Batch evaluation (§7) is not yet exposed as a tool — file an issue if you need it. -### `authzen_evaluate` - -POST the bundle to the configured PDP's `/access/v1/evaluation` (or equivalent) endpoint, return the decision. - -| Parameter | Required | Description | -| ----------- | -------- | ----------------------------------------------------------------------------------------------- | -| `subject` | yes | JSON object: `{"type": "user", "id": "alice", "properties": {...}}` | -| `resource` | yes | JSON object: `{"type": "document", "id": "doc-1", "properties": {...}}` | -| `action` | yes | JSON object: `{"name": "read", "properties": {...}}` | -| `context` | no | Free-form JSON object with runtime context (IP, time, MFA strength, etc). | -| `pdp_url` | no | Override the default PDP URL for this call. | +## Install -Returns AuthZEN's `{"decision": , "context": {...}}` as JSON. +```bash +go install github.com/0-draft/mcp-authzen@latest +``` -## Configuration +Pre-built signed binaries are on the [releases page](https://github.com/0-draft/mcp-authzen/releases). -Set the default PDP endpoint via env: +## Quickstart ```bash +# Point at a real PDP (or your local opa-authzen-plugin on :8181) export AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation -``` - -Or pass `pdp_url` on every call. If neither is set, the tool returns an error. -## Install +# Optional: PDP behind auth. Bare values are auto-prefixed with "Bearer "; +# values starting with "Bearer " or "Basic " pass through verbatim. +# export AUTHZEN_PDP_TOKEN=eyJhbGciOi... -```bash -go install github.com/0-draft/mcp-authzen@latest +# Run the smoke test (spins up an in-process fake PDP, exercises the +# full MCP handshake, asserts the decision is forwarded correctly) +make smoke ``` -Or grab a signed binary from the [releases page](https://github.com/0-draft/mcp-authzen/releases). - -## Use with Claude Code +## Wire it to Claude Code ```bash AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation \ @@ -52,11 +44,11 @@ Then in a session: > Can `alice` (role=admin) `delete` `doc-42` (owner=bob)? -Claude builds the AuthZEN request, calls `authzen_evaluate`, returns the PDP's verdict — auditable, deterministic, and your policy never leaves your PDP. +The model builds the AuthZEN bundle, calls `authzen_evaluate`, returns the PDP's decision. -## Use with Cursor / other MCP clients +## Wire it to Cursor / other clients -```json +```jsonc { "mcpServers": { "authzen": { @@ -67,17 +59,50 @@ Claude builds the AuthZEN request, calls `authzen_evaluate`, returns the PDP's v } ``` -## Test against a local PDP +## Tool: `authzen_evaluate` + +| Param | Required | Description | +| ---------- | -------- | -------------------------------------------------------------------------- | +| `subject` | yes | JSON object. AuthZEN §5.1, e.g. `{"type":"user","id":"alice"}`. | +| `resource` | yes | JSON object. AuthZEN §5.2, e.g. `{"type":"doc","id":"doc-1"}`. | +| `action` | yes | JSON object. AuthZEN §5.3, e.g. `{"name":"read"}`. | +| `context` | no | JSON object with runtime context. AuthZEN §5.4. | +| `pdp_url` | no | Override `AUTHZEN_PDP_URL` for this call. | + +Returns AuthZEN's `{"decision": , "context": {...}}` as JSON. + +## Test against opa-authzen-plugin -Spin up [`opa-authzen-plugin`](https://github.com/kanywst/opa-authzen-plugin) (or any AuthZEN PDP) on `:8181`, then: +[`kanywst/opa-authzen-plugin`](https://github.com/kanywst/opa-authzen-plugin) is a reference AuthZEN PDP built on OPA. ```bash -AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation mcp-authzen +# In one terminal — start the PDP on :8181 +git clone https://github.com/kanywst/opa-authzen-plugin +cd opa-authzen-plugin && make run + +# In another — wire mcp-authzen +export AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation +mcp-authzen +``` + +## Layout + +Flat by design. A single-binary MCP server with one tool does not need +`cmd/`, `internal/`, or `pkg/`. When batch (`/access/v1/evaluations`) or +the search APIs (§8) land, they get sibling files, not subpackages. + +```text +. +├── main.go # server bootstrap + tool registration +├── main_test.go # httptest-driven PDP round-trips +├── scripts/smoke.sh +├── .goreleaser.yml +└── .github/ ``` -## Verifying a release +## Verify a release -Each release ships a `cosign`-signed checksum file (keyless, Sigstore via GitHub OIDC) and a CycloneDX SBOM. To verify before installing: +Releases ship a `cosign`-signed checksum file (Sigstore keyless via GitHub OIDC) and a CycloneDX SBOM per archive. ```bash TAG=v0.1.0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3eadc0c --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/0-draft/mcp-authzen + +go 1.26.3 + +require github.com/mark3labs/mcp-go v0.54.1 + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d7a17e9 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +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= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +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/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/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0= +github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +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/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..61a0dcd --- /dev/null +++ b/main.go @@ -0,0 +1,211 @@ +// mcp-authzen is a Model Context Protocol (MCP) server that fronts an +// OpenID AuthZEN 1.0 compliant Policy Decision Point (PDP). It exposes +// `authzen_evaluate` as an MCP tool so an LLM agent can ask "can subject S +// perform action A on resource R?" and route the question to a real PDP. +// +// The PDP endpoint can be configured via the AUTHZEN_PDP_URL environment +// variable or overridden per-call via the optional `pdp_url` argument. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var version = "dev" + +// authzenRequest matches the OpenID AuthZEN 1.0 Evaluation API request body. +// +// https://openid.net/specs/authorization-api-1_0.html +type authzenRequest struct { + Subject json.RawMessage `json:"subject"` + Resource json.RawMessage `json:"resource"` + Action json.RawMessage `json:"action"` + Context json.RawMessage `json:"context,omitempty"` +} + +type authzenResponse struct { + Decision bool `json:"decision"` + Context json.RawMessage `json:"context,omitempty"` +} + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "--version", "-v", "version": + fmt.Printf("mcp-authzen %s\n", version) + return + case "--help", "-h", "help": + fmt.Println(`mcp-authzen — MCP server fronting an OpenID AuthZEN 1.0 PDP. + +Usage: + mcp-authzen Run as an MCP stdio server. + mcp-authzen --version Print version. + +Configuration: + AUTHZEN_PDP_URL Default PDP evaluation endpoint, e.g. + http://localhost:8181/access/v1/evaluation + +Tools exposed: + authzen_evaluate POST to the PDP's evaluation endpoint with a + subject/resource/action/context bundle.`) + return + } + } + + s := server.NewMCPServer( + "mcp-authzen", + version, + server.WithToolCapabilities(false), + ) + + s.AddTool( + mcp.NewTool("authzen_evaluate", + mcp.WithDescription( + "Ask an OpenID AuthZEN 1.0 PDP whether a subject is allowed "+ + "to perform an action on a resource. Returns the PDP's "+ + "decision (true/false) and optional context."), + mcp.WithString("subject", + mcp.Required(), + mcp.Description(`JSON object describing the principal. Per AuthZEN: `+ + `{"type": "user", "id": "alice", "properties": {...}}.`), + ), + mcp.WithString("resource", + mcp.Required(), + mcp.Description(`JSON object describing the target. Per AuthZEN: `+ + `{"type": "document", "id": "doc-1", "properties": {...}}.`), + ), + mcp.WithString("action", + mcp.Required(), + mcp.Description(`JSON object describing the action. Per AuthZEN: `+ + `{"name": "read", "properties": {...}}.`), + ), + mcp.WithString("context", + mcp.Description(`Optional JSON object with runtime context `+ + `(IP, time, MFA strength, etc).`), + ), + mcp.WithString("pdp_url", + mcp.Description(`Override the AUTHZEN_PDP_URL env. Must be a `+ + `full URL to the evaluation endpoint.`), + ), + ), + evaluate, + ) + + if err := server.ServeStdio(s); err != nil { + log.Fatalf("mcp-authzen: %v", err) + } +} + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +func evaluate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + pdpURL := req.GetString("pdp_url", "") + if pdpURL == "" { + pdpURL = os.Getenv("AUTHZEN_PDP_URL") + } + if pdpURL == "" { + return mcp.NewToolResultError( + "no PDP URL: set AUTHZEN_PDP_URL or pass pdp_url"), nil + } + // Constrain the outbound target so a model-supplied `pdp_url` can't be used + // to scan internal addresses with non-HTTP schemes or omitted hosts. + if u, perr := url.Parse(pdpURL); perr != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return mcp.NewToolResultError("invalid pdp_url: must be an absolute http(s) URL with a host"), nil + } + + subject, err := parseJSONArg(req, "subject", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + resource, err := parseJSONArg(req, "resource", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + action, err := parseJSONArg(req, "action", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + contextRaw, err := parseJSONArg(req, "context", false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + body, _ := json.Marshal(authzenRequest{ + Subject: subject, + Resource: resource, + Action: action, + Context: contextRaw, + }) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pdpURL, bytes.NewReader(body)) + if err != nil { + return mcp.NewToolResultError("build request: " + err.Error()), nil + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + if token := os.Getenv("AUTHZEN_PDP_TOKEN"); token != "" { + if !strings.HasPrefix(token, "Bearer ") && !strings.HasPrefix(token, "Basic ") { + token = "Bearer " + token + } + httpReq.Header.Set("Authorization", token) + } + + res, err := httpClient.Do(httpReq) + if err != nil { + return mcp.NewToolResultError("PDP request failed: " + err.Error()), nil + } + defer func() { _ = res.Body.Close() }() + + // Cap the response at 1 MiB; AuthZEN responses are tiny and we don't want + // a misbehaving PDP to OOM the server. + raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return mcp.NewToolResultError("read PDP response: " + err.Error()), nil + } + + if res.StatusCode >= 400 { + return mcp.NewToolResultError(fmt.Sprintf( + "PDP returned %d: %s", res.StatusCode, strings.TrimSpace(string(raw)))), nil + } + + var decoded authzenResponse + if err := json.Unmarshal(raw, &decoded); err != nil { + return mcp.NewToolResultError("PDP response is not valid AuthZEN JSON: " + err.Error()), nil + } + + out, _ := json.MarshalIndent(decoded, "", " ") + return mcp.NewToolResultText(string(out)), nil +} + +func parseJSONArg(req mcp.CallToolRequest, name string, required bool) (json.RawMessage, error) { + s := req.GetString(name, "") + if s == "" { + if required { + return nil, fmt.Errorf("missing required arg %q", name) + } + return nil, nil + } + // AuthZEN entities (subject, resource, action, context) are all JSON + // objects — reject arrays / scalars early instead of letting the PDP do it. + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + return nil, fmt.Errorf("arg %q is not valid JSON: %w", name, err) + } + if _, ok := v.(map[string]any); !ok { + return nil, fmt.Errorf("arg %q must be a JSON object", name) + } + return json.RawMessage(s), nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..8dbe087 --- /dev/null +++ b/main_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func callEval(t *testing.T, args map[string]any) *mcp.CallToolResult { + t.Helper() + req := mcp.CallToolRequest{} + req.Params.Name = "authzen_evaluate" + req.Params.Arguments = args + res, err := evaluate(context.Background(), req) + if err != nil { + t.Fatalf("evaluate returned non-nil err: %v", err) + } + return res +} + +func resultText(t *testing.T, res *mcp.CallToolResult) string { + t.Helper() + if len(res.Content) == 0 { + t.Fatal("empty content") + } + tc, ok := mcp.AsTextContent(res.Content[0]) + if !ok { + t.Fatalf("first content is not text: %T", res.Content[0]) + } + return tc.Text +} + +// fakePDP returns the configured decision and echoes the request for inspection. +func fakePDP(t *testing.T, decision bool, capture *authzenRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if capture != nil { + _ = json.Unmarshal(body, capture) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authzenResponse{Decision: decision}) + })) +} + +func TestEvaluate_Allow(t *testing.T) { + got := &authzenRequest{} + pdp := fakePDP(t, true, got) + defer pdp.Close() + + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"type":"user","id":"alice"}`, + "resource": `{"type":"doc","id":"doc-1"}`, + "action": `{"name":"read"}`, + }) + if res.IsError { + t.Fatalf("expected success, got error: %s", resultText(t, res)) + } + if !strings.Contains(resultText(t, res), `"decision": true`) { + t.Fatalf("expected decision=true in output: %s", resultText(t, res)) + } + if string(got.Subject) != `{"type":"user","id":"alice"}` { + t.Fatalf("subject not forwarded: %s", string(got.Subject)) + } +} + +func TestEvaluate_Deny(t *testing.T) { + pdp := fakePDP(t, false, nil) + defer pdp.Close() + + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"type":"user","id":"bob"}`, + "resource": `{"type":"doc","id":"doc-1"}`, + "action": `{"name":"delete"}`, + }) + if !strings.Contains(resultText(t, res), `"decision": false`) { + t.Fatalf("expected decision=false: %s", resultText(t, res)) + } +} + +func TestEvaluate_MissingPDP(t *testing.T) { + t.Setenv("AUTHZEN_PDP_URL", "") + res := callEval(t, map[string]any{ + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error when PDP URL unset") + } +} + +func TestEvaluate_BadJSON(t *testing.T) { + res := callEval(t, map[string]any{ + "pdp_url": "http://example.invalid", + "subject": `{not json}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error for malformed subject JSON") + } +} + +func TestEvaluate_BearerToken(t *testing.T) { + t.Setenv("AUTHZEN_PDP_TOKEN", "s3cret") + + var gotAuth string + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authzenResponse{Decision: true}) + })) + defer pdp.Close() + + _ = callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if gotAuth != "Bearer s3cret" { + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer s3cret") + } +} + +func TestEvaluate_BearerToken_PreservesPrefix(t *testing.T) { + t.Setenv("AUTHZEN_PDP_TOKEN", "Bearer already-prefixed") + + var gotAuth string + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authzenResponse{Decision: true}) + })) + defer pdp.Close() + + _ = callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if gotAuth != "Bearer already-prefixed" { + t.Fatalf("Authorization header = %q; expected raw passthrough", gotAuth) + } +} + +func TestEvaluate_RejectsNonHTTPScheme(t *testing.T) { + res := callEval(t, map[string]any{ + "pdp_url": "file:///etc/passwd", + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error for non-http(s) scheme") + } +} + +func TestEvaluate_RejectsRelativeURL(t *testing.T) { + res := callEval(t, map[string]any{ + "pdp_url": "/local/path", + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error for relative URL (missing host)") + } +} + +func TestEvaluate_RejectsArraySubject(t *testing.T) { + pdp := fakePDP(t, true, nil) + defer pdp.Close() + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `["alice"]`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error: subject must be a JSON object") + } + if !strings.Contains(resultText(t, res), "must be a JSON object") { + t.Fatalf("expected object-only error in: %s", resultText(t, res)) + } +} + +func TestEvaluate_PDP5xx(t *testing.T) { + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer pdp.Close() + + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error from 5xx response") + } + if !strings.Contains(resultText(t, res), "500") { + t.Fatalf("expected status 500 in error: %s", resultText(t, res)) + } +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..2516398 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# End-to-end smoke test for mcp-authzen. +# +# 1. Starts a fake AuthZEN PDP (Python) on a free port that always returns +# {"decision": true}. +# 2. Runs mcp-authzen pointed at it. +# 3. Drives an MCP initialize → tools/list → tools/call(authzen_evaluate) +# sequence over stdio. +# 4. Asserts the decision was forwarded back to the caller. +# +# Exit codes: +# 0 decision propagated correctly +# 1 decision missing or wrong +# 2 protocol / setup failure + +set -euo pipefail + +BIN="${1:-./mcp-authzen}" +if [[ ! -x "$BIN" ]]; then + echo "build first: go build ." >&2 + exit 2 +fi + +PORT=18181 +PDP_LOG=$(mktemp) +# shellcheck disable=SC2329 # registered as EXIT trap below +cleanup() { + if [[ -n "${PDP_PID:-}" ]] && kill -0 "$PDP_PID" 2>/dev/null; then + kill "$PDP_PID" 2>/dev/null || true + fi + rm -f "$PDP_LOG" +} +trap cleanup EXIT + +python3 - "$PORT" >"$PDP_LOG" 2>&1 <<'PY' & +import json, sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +class FakePDP(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + _ = self.rfile.read(length) + body = json.dumps({"decision": True}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass + +HTTPServer(("127.0.0.1", int(sys.argv[1])), FakePDP).serve_forever() +PY +PDP_PID=$! +disown "$PDP_PID" 2>/dev/null || true + +# Wait for the fake PDP to be listening (pure-bash TCP probe) +for _ in {1..30}; do + (echo >/dev/tcp/127.0.0.1/$PORT) 2>/dev/null && break + sleep 0.1 +done + +OUT=$(printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"authzen_evaluate","arguments":{"subject":"{\"type\":\"user\",\"id\":\"alice\"}","resource":"{\"type\":\"doc\",\"id\":\"d1\"}","action":"{\"name\":\"read\"}"}}}' \ + | AUTHZEN_PDP_URL="http://127.0.0.1:${PORT}/access/v1/evaluation" "$BIN") + +DECISION=$(printf '%s\n' "$OUT" | tail -1 | jq -r '.result.content[0].text' | jq -r '.decision // empty') + +case "$DECISION" in + true) echo "✓ smoke: decision=true forwarded from fake PDP"; exit 0 ;; + false) echo "✗ smoke: decision=false (fake PDP returned true; mismatch)"; exit 1 ;; + *) echo "✗ smoke: no decision field in response. payload:"; printf '%s\n' "$OUT"; exit 2 ;; +esac