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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ Prebuilt binaries are on the [releases page](https://github.com/mickamy/rollcall

## Status

Early development. `rollcall proxy` speaks the PostgreSQL protocol: it relays authentication untouched, sees every statement on both the simple and the extended query protocol, and can refuse one before it reaches the server. The policy that decides what to refuse and the access ledger are being built on top of it; today everything is allowed.
Early development. `rollcall proxy` speaks the PostgreSQL protocol: it relays authentication untouched, sees every statement on both the simple and the extended query protocol, and refuses one before it reaches the server. A policy file maps each database role to an agent and can make it read-only; without `-policy`, every statement is allowed. The access ledger is next.

A read-only role is enforced in depth: the session is set read-only on the server (so writes through functions such as `nextval` are refused too), the proxy blocks attempts to turn that off, and obvious writes are refused early with a clear message. A lexical proxy cannot fully sandbox a role that already holds write privileges; for the strongest guarantee, also grant that database role only `SELECT`.

The proxy speaks plaintext on both sides and answers `SSLRequest` with `N`, so `sslmode=prefer` clients fall back to plaintext. Keep the listener on loopback or a pod-local network until TLS lands.

Expand All @@ -34,6 +36,22 @@ rollcall proxy -upstream 127.0.0.1:5432 # listens on 127.0.0.1:6432
PGHOST=127.0.0.1 PGPORT=6432 psql -U agent_claude_ops prod
```

Enforce a read-only role with a policy file:

```yaml
# policy.yaml
fail: closed
roles:
agent_ops:
agent: claude-ops
purpose: incident-investigation
read_only: true
```

```sh
rollcall proxy -upstream 127.0.0.1:5432 -policy policy.yaml
```

```sh
rollcall proxy -h
rollcall help
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
module github.com/mickamy/rollcall

go 1.27.0

require gopkg.in/yaml.v3 v3.0.1
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
5 changes: 5 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ func TestRun(t *testing.T) {
wantCode: exit.Error,
wantErr: cli.Name + ": listen tcp",
},
"proxy with a missing policy file": {
args: []string{"proxy", "-upstream", "127.0.0.1:5432", "-policy", "/no/such/policy.yaml"},
wantCode: exit.Error,
wantErr: cli.Name + ": read policy:",
},
}

for name, tt := range tests {
Expand Down
16 changes: 15 additions & 1 deletion internal/cli/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,23 @@ import (

"github.com/mickamy/rollcall/internal/exit"
"github.com/mickamy/rollcall/internal/pg"
"github.com/mickamy/rollcall/internal/policy"
"github.com/mickamy/rollcall/internal/proxy"
"github.com/mickamy/rollcall/internal/wire"
)

const (
defaultListen = "127.0.0.1:6432"
listenUsage = "address to accept client connections on"
upstreamUsage = "address of the upstream database"
policyUsage = "path to a policy file; without one every statement is allowed"
)

func runProxy(ctx context.Context, args []string, std IO) int {
fs := newFlagSet("proxy", std.Err, printProxyUsage)
listen := fs.String("listen", defaultListen, listenUsage)
upstream := fs.String("upstream", "", upstreamUsage)
policyPath := fs.String("policy", "", policyUsage)
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return exit.OK
Expand All @@ -44,6 +48,15 @@ func runProxy(ctx context.Context, args []string, std IO) int {
return fail(std, err)
}

guard := wire.AllowAll
if *policyPath != "" {
p, err := policy.Load(*policyPath)
if err != nil {
return fail(std, err)
}
guard = p
}

var lc net.ListenConfig
ln, err := lc.Listen(ctx, "tcp", *listen)
if err != nil {
Expand All @@ -57,7 +70,7 @@ func runProxy(ctx context.Context, args []string, std IO) int {
logger.Warn("listening outside loopback: clients and the upstream are served in plaintext", "addr", *listen)
}

srv := proxy.Server{Upstream: *upstream, Dialect: pg.Dialect{}, Logger: logger}
srv := proxy.Server{Upstream: *upstream, Dialect: pg.Dialect{}, Guard: guard, Logger: logger}
if err := srv.Serve(ctx, ln); err != nil {
return fail(std, err)
}
Expand Down Expand Up @@ -104,4 +117,5 @@ func printProxyUsage(w io.Writer) {
fmt.Fprint(w, "Flags:\n")
fmt.Fprintf(w, " -upstream ADDR %s (required)\n", upstreamUsage)
fmt.Fprintf(w, " -listen ADDR %s (default %q)\n", listenUsage, defaultListen)
fmt.Fprintf(w, " -policy PATH %s\n", policyUsage)
}
29 changes: 29 additions & 0 deletions internal/pg/pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,35 @@ func (s *session) Handshake() (wire.Startup, error) {
}
}

// Prime runs one statement on the upstream and consumes its response, before
// Frontend and Backend start, so it needs no locking. It fails if the upstream
// reports an error, so a failed read-only setup tears the session down.
func (s *session) Prime(sql string) error {
if err := writeMessage(s.uw, typeQuery, append([]byte(sql), 0)); err != nil {
return err
}
if err := flush(s.uw); err != nil {
return err
}

for {
typ, body, err := readMessage(s.ur, maxAuthMessage)
if err != nil {
return fmt.Errorf("read upstream during prime: %w", err)
}
switch typ {
case typeErrorResponse:
return fmt.Errorf("prime %q: %s", sql, errorMessage(body))
case typeReadyForQuery:
if len(body) == 1 {
s.tx = body[0]
}

return nil
}
}
}

// Frontend relays client messages, consulting h for every statement. Extended
// messages are held until their Sync so a batch can be accepted or rejected as
// a unit. Output is flushed whenever the client has nothing more buffered.
Expand Down
3 changes: 3 additions & 0 deletions internal/policy/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package policy

var Parse = parse
151 changes: 151 additions & 0 deletions internal/policy/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Package policy resolves a per-session guard from a YAML file: it maps a
// connection's database role to an agent, a purpose, and the statements that
// role may run.
package policy

import (
"bytes"
"fmt"
"os"

"gopkg.in/yaml.v3"

"github.com/mickamy/rollcall/internal/sqlscan"
"github.com/mickamy/rollcall/internal/wire"
)

// readOnlyPrime is run on the upstream for a read-only role. It makes the server
// refuse every write, including writes performed through functions such as
// nextval, which the statement classifier cannot see.
const readOnlyPrime = "SET default_transaction_read_only = on"

// Policy decides what each database role may do. The zero value denies nothing;
// load a file to enforce rules.
type Policy struct {
// FailClosed denies connections whose role is not listed. The default,
// fail-open, lets unlisted roles through unchanged.
FailClosed bool
Roles map[string]Role
}

// Role is the set of rules bound to one database role.
type Role struct {
Agent string
Purpose string
ReadOnly bool
}

var _ wire.Guard = (*Policy)(nil)

type file struct {
Fail string `yaml:"fail"`
Roles map[string]role `yaml:"roles"`
}

type role struct {
Agent string `yaml:"agent"`
Purpose string `yaml:"purpose"`
ReadOnly bool `yaml:"read_only"`
}

// Load reads and validates a policy file.
func Load(path string) (Policy, error) {
data, err := os.ReadFile(path)
if err != nil {
return Policy{}, fmt.Errorf("read policy: %w", err)
}

return parse(data)
}

func parse(data []byte) (Policy, error) {
var f file
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&f); err != nil {
return Policy{}, fmt.Errorf("parse policy: %w", err)
}

failClosed, err := parseFail(f.Fail)
if err != nil {
return Policy{}, err
}

p := Policy{FailClosed: failClosed, Roles: make(map[string]Role, len(f.Roles))}
for name, r := range f.Roles {
p.Roles[name] = Role(r)
}

return p, nil
}

func parseFail(s string) (bool, error) {
switch s {
case "", "open":
return false, nil
case "closed":
return true, nil
default:
return false, fmt.Errorf("policy: fail must be \"open\" or \"closed\", got %q", s)
}
}

// Resolve returns the enforcement for a session, binding the rules of the role
// that matches the connection's database user.
func (p Policy) Resolve(startup wire.Startup) wire.Enforcement {
role, ok := p.Roles[startup.User]
if !ok {
return p.unlisted(startup.User)
}

if !role.ReadOnly {
return wire.Enforcement{Handler: allow()}
}

return wire.Enforcement{
Prime: []string{readOnlyPrime},
Handler: wire.HandlerFunc(readOnly),
}
}

func (p Policy) unlisted(user string) wire.Enforcement {
if !p.FailClosed {
return wire.Enforcement{Handler: allow()}
}

return wire.Enforcement{Handler: wire.HandlerFunc(func(wire.Statement) wire.Verdict {
return wire.Verdict{
Deny: true,
Message: fmt.Sprintf("no policy for role %q", user),
Hint: "add the role to the policy, or connect as a configured role",
}
})}
}

// readOnly denies statements that write or that could turn read-only mode off.
// The server-side read-only transaction is the real guarantee; this gives a
// clear, early refusal and stops the client from disabling it.
func readOnly(stmt wire.Statement) wire.Verdict {
for _, f := range sqlscan.Scan(stmt.SQL) {
if f.DisablesReadOnly {
return wire.Verdict{
Deny: true,
Message: "changing this connection to read-write is not allowed",
Hint: "this connection is read-only; use a role that permits writes",
}
}
if f.Kind.Mutating() {
return wire.Verdict{
Deny: true,
Message: fmt.Sprintf("%s is not allowed: this connection is read-only", f.Kind),
Hint: "use a role that permits writes, or request approval",
}
}
}

return wire.Verdict{}
}

func allow() wire.Handler {
return wire.HandlerFunc(func(wire.Statement) wire.Verdict { return wire.Verdict{} })
}
Loading
Loading