Skip to content
Open
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
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,25 @@ jobs:

- name: Run tests
run: make test

proto:
name: Proto lint + breaking
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# buf breaking needs main's history to diff against.
fetch-depth: 0

- uses: bufbuild/buf-action@v1
with:
input: proto
lint: true
# `format: true` makes the action run `buf format -d --exit-code`,
# failing the job on any unformatted .proto. Catches drift before
# generated code can diverge.
format: true
# Only run breaking on PRs (push to main has nothing to diff against).
breaking: ${{ github.event_name == 'pull_request' }}
breaking_against: 'https://github.com/${{ github.repository }}.git#branch=main,subdir=proto'
27 changes: 27 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,30 @@ generate-graphql:
generate-db-template:
cp -rf internal/storage/db/provider_template internal/storage/db/${dbname}
find internal/storage/db/${dbname} -type f -exec sed -i -e 's/provider_template/${dbname}/g' {} \;

# ----------------------------------------------------------------------------
# Protobuf (Phase 0+): public-API source of truth under ./proto.
# `buf` is installed on demand into $(GOBIN) if missing.
# ----------------------------------------------------------------------------
BUF ?= $(shell command -v buf 2>/dev/null)
BUF_VERSION ?= v1.47.2

.PHONY: proto-tools proto-lint proto-breaking proto-gen

proto-tools:
@if [ -z "$(BUF)" ]; then \
echo "Installing buf $(BUF_VERSION) via go install"; \
go install github.com/bufbuild/buf/cmd/buf@$(BUF_VERSION); \
fi

proto-lint: proto-tools
cd proto && buf lint

# Compare the working tree's proto against origin/main; fails on breaking changes.
# Override BUF_BREAKING_AGAINST for local runs (e.g. "main" or a SHA).
BUF_BREAKING_AGAINST ?= .git#branch=origin/main,subdir=proto
proto-breaking: proto-tools
cd proto && buf breaking --against '../$(BUF_BREAKING_AGAINST)'

proto-gen: proto-tools
cd proto && buf dep update && buf generate
177 changes: 177 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package cmd

import (
"context"
"os"
"os/signal"
"syscall"

"github.com/rs/zerolog"
"github.com/spf13/cobra"

"github.com/authorizerdev/authorizer/internal/audit"
"github.com/authorizerdev/authorizer/internal/authorization"
"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/email"
"github.com/authorizerdev/authorizer/internal/events"
"github.com/authorizerdev/authorizer/internal/grpcsrv"
"github.com/authorizerdev/authorizer/internal/mcp"
"github.com/authorizerdev/authorizer/internal/memory_store"
"github.com/authorizerdev/authorizer/internal/service"
"github.com/authorizerdev/authorizer/internal/sms"
"github.com/authorizerdev/authorizer/internal/storage"
"github.com/authorizerdev/authorizer/internal/token"
)

// mcpArgs are the MCP-subcommand-only flags. The root command's flags
// (--database-type, --client-id, --jwt-secret, ...) are inherited by the
// subcommand automatically since they live on RootCmd.
var mcpArgs struct {
// bearer is propagated as `Authorization: Bearer <bearer>` on every
// outgoing gRPC call. Without it the MCP server runs anonymously —
// fine for the `meta` tool (public) but identity-bearing tools
// (`profile`, `permissions`) won't have a caller to attribute to.
bearer string
}

// mcpCmd serves Authorizer's MCP surface over stdio. Designed to be wired
// into Claude Code or any other MCP host via:
//
// claude mcp add authorizer -- /path/to/authorizer mcp --client-id=... \
// --database-type=sqlite --database-url=auth.db --mcp-bearer=$TOKEN
//
// Which tools are exposed is declared at the proto layer via the
// `(authorizer.common.v1.mcp_tool).exposed` option; the MCP server discovers
// them at startup.
//
// Transport: STDIO ONLY. The MCP server has no auth/rate-limit interceptors
// of its own — the security model relies on the OS-level trust boundary of
// the subprocess. See internal/mcp/server.go's Server type comment.
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "Serve Authorizer's MCP tool surface over stdio",
Long: "Exposes a subset of Authorizer's gRPC methods (those marked " +
"(authorizer.common.v1.mcp_tool).exposed=true in proto) as MCP " +
"tools, suitable for use with Claude Code or any MCP-compatible " +
"host. Stdio is the only supported transport.",
Run: runMCP,
}

func init() {
mcpCmd.Flags().StringVar(&mcpArgs.bearer, "mcp-bearer", "",
"Bearer token to attach to every outgoing gRPC call (carries the "+
"user identity for tools like Profile / Permissions / Session). "+
"When unset the MCP server runs anonymously; public tools (Meta) "+
"still work but identity-bearing tools will fail authn.")
RootCmd.AddCommand(mcpCmd)
}

func runMCP(_ *cobra.Command, _ []string) {
// MCP stdio mode: stderr-only logging so it doesn't interleave with the
// JSON-RPC framing on stdout.
log := zerolog.New(os.Stderr).With().Timestamp().Logger()

// Wire all subsystems an MCP-exposed tool might need. As more ops
// migrate into internal/service, this list stays the same — the
// service-provider dependencies don't change per op, only the methods
// on the provider do.
storageProvider, err := storage.New(&rootArgs.config, &storage.Dependencies{Log: &log})
if err != nil {
log.Fatal().Err(err).Msg("failed to create storage provider")
}
memoryStoreProvider, err := memory_store.New(&rootArgs.config, &memory_store.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create memory store provider")
}
tokenProvider, err := token.New(&rootArgs.config, &token.Dependencies{
Log: &log,
MemoryStoreProvider: memoryStoreProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create token provider")
}
emailProvider, err := email.New(&rootArgs.config, &email.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create email provider")
}
smsProvider, err := sms.New(&rootArgs.config, &sms.Dependencies{Log: &log})
if err != nil {
log.Fatal().Err(err).Msg("failed to create sms provider")
}
auditProvider := audit.New(&audit.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
eventsProvider, err := events.New(&rootArgs.config, &events.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create events provider")
}

authorizationProvider, err := authorization.New(
&authorization.Config{CacheTTL: 0},
&authorization.Dependencies{
Log: &log,
StorageProvider: storageProvider,
MemoryStoreProvider: memoryStoreProvider,
},
)
if err != nil {
log.Fatal().Err(err).Msg("failed to create authorization provider")
}

svc, err := service.New(&rootArgs.config, &service.Dependencies{
Log: &log,
AuditProvider: auditProvider,
AuthorizationProvider: authorizationProvider,
EmailProvider: emailProvider,
EventsProvider: eventsProvider,
MemoryStoreProvider: memoryStoreProvider,
SMSProvider: smsProvider,
StorageProvider: storageProvider,
TokenProvider: tokenProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create service provider")
}

grpcSrv, err := grpcsrv.New(":0", &grpcsrv.Dependencies{
Log: &log,
Config: &rootArgs.config,
ServiceProvider: svc,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create grpc server")
}

mcpSrv, err := mcp.New(&log, grpcSrv.GRPCServer(), mcp.Options{
Name: "authorizer",
Version: constants.VERSION,
Bearer: mcpArgs.bearer,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create mcp server")
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
<-c
cancel()
}()

if err := mcpSrv.RunStdio(ctx); err != nil {
log.Error().Err(err).Msg("mcp server exited")
os.Exit(1)
}
}
63 changes: 60 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package cmd
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"strconv"
"strings"
"time"

Expand All @@ -25,8 +27,10 @@ import (
"github.com/authorizerdev/authorizer/internal/memory_store"
"github.com/authorizerdev/authorizer/internal/metrics"
"github.com/authorizerdev/authorizer/internal/oauth"
"github.com/authorizerdev/authorizer/internal/grpcsrv"
"github.com/authorizerdev/authorizer/internal/rate_limit"
"github.com/authorizerdev/authorizer/internal/server"
"github.com/authorizerdev/authorizer/internal/service"
"github.com/authorizerdev/authorizer/internal/sms"
"github.com/authorizerdev/authorizer/internal/storage"
"github.com/authorizerdev/authorizer/internal/token"
Expand Down Expand Up @@ -102,6 +106,14 @@ func init() {
f.IntVar(&rootArgs.config.GraphQLMaxAliases, "graphql-max-aliases", 30, "Maximum total number of aliased fields per GraphQL operation")
f.Int64Var(&rootArgs.config.GraphQLMaxBodyBytes, "graphql-max-body-bytes", 1<<20, "Maximum allowed GraphQL request body size in bytes (default 1MB)")

// gRPC server flags. Port 9091 avoids collision with the metrics
// listener which defaults to 8081 (and with the HTTP listener on 8080).
f.IntVar(&rootArgs.config.GRPCPort, "grpc-port", 9091, "Port the gRPC server listens on")
f.BoolVar(&rootArgs.config.EnableGRPCReflection, "enable-grpc-reflection", true, "Enable the gRPC server-reflection service")
f.StringVar(&rootArgs.config.GRPCTLSCert, "grpc-tls-cert", "", "Path to the TLS certificate for the gRPC server")
f.StringVar(&rootArgs.config.GRPCTLSKey, "grpc-tls-key", "", "Path to the TLS private key for the gRPC server")
f.BoolVar(&rootArgs.config.GRPCInsecure, "grpc-insecure", false, "Allow the gRPC server to run without TLS (dev only)")

// Organization flags
f.StringVar(&rootArgs.config.OrganizationLogo, "organization-logo", defaultOrganizationLogo, "Logo of the organization")
f.StringVar(&rootArgs.config.OrganizationName, "organization-name", defaultOrganizationName, "Name of the organization")
Expand Down Expand Up @@ -333,9 +345,20 @@ func applyFlagDefaults() {
// Run the service
func runRoot(c *cobra.Command, args []string) {
applyFlagDefaults()
if rootArgs.server.HTTPPort == rootArgs.server.MetricsPort {
fmt.Fprintf(os.Stderr, "invalid server ports: --http-port and --metrics-port must differ (metrics are always served on a dedicated listener)\n")
os.Exit(1)
// All three listeners (HTTP, metrics, gRPC) bind concurrently; any
// collision is unrecoverable at runtime, so we fail fast at startup.
ports := map[string]int{
"--http-port": rootArgs.server.HTTPPort,
"--metrics-port": rootArgs.server.MetricsPort,
"--grpc-port": rootArgs.config.GRPCPort,
}
for nameA, a := range ports {
for nameB, b := range ports {
if nameA < nameB && a == b {
fmt.Fprintf(os.Stderr, "invalid server ports: %s (%d) and %s (%d) must differ — each listener binds independently\n", nameA, a, nameB, b)
os.Exit(1)
}
}
}

// Refuse to start without an admin secret. The previous default of
Expand Down Expand Up @@ -530,6 +553,23 @@ func runRoot(c *cobra.Command, args []string) {
StorageProvider: storageProvider,
})

// Transport-agnostic service layer that hosts public-API operations.
// GraphQL, gRPC, and REST surfaces all delegate to this.
serviceProvider, err := service.New(&rootArgs.config, &service.Dependencies{
Log: &log,
AuditProvider: auditProvider,
AuthorizationProvider: authorizationProvider,
EmailProvider: emailProvider,
EventsProvider: eventsProvider,
MemoryStoreProvider: memoryStoreProvider,
SMSProvider: smsProvider,
StorageProvider: storageProvider,
TokenProvider: tokenProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create service provider")
}

httpProvider, err := http_handlers.New(&rootArgs.config, &http_handlers.Dependencies{
Log: &log,
AuditProvider: auditProvider,
Expand All @@ -543,15 +583,32 @@ func runRoot(c *cobra.Command, args []string) {
OAuthProvider: oauthProvider,
RateLimitProvider: rateLimitProvider,
AuthorizationProvider: authorizationProvider,
ServiceProvider: serviceProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create http provider")
}

// gRPC server — listens on --grpc-port. The REST gateway built by
// server.Run wraps this same gRPC server in-process so /v1/* REST
// calls translate to local gRPC method invocations (no network hop).
grpcAddr := net.JoinHostPort(rootArgs.server.Host, strconv.Itoa(rootArgs.config.GRPCPort))
grpcSrv, err := grpcsrv.New(grpcAddr, &grpcsrv.Dependencies{
Log: &log,
Config: &rootArgs.config,
ServiceProvider: serviceProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create grpc server")
}
rootArgs.server.GRPCPort = rootArgs.config.GRPCPort

// Prepare server
deps := &server.Dependencies{
Log: &log,
AppConfig: &rootArgs.config,
HTTPProvider: httpProvider,
GRPCServer: grpcSrv,
}
// Create the server
svr, err := server.New(&rootArgs.server, deps)
Expand Down
Loading