Compare commits
3 Commits
mai/bose/d
...
mai/planck
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
482eafd03d | ||
|
|
8ceb39d07e | ||
|
|
9713478247 |
@@ -186,20 +186,30 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
}
|
||||
// Per-user RLS auth (t-paliad-156): hand the JWT secret to
|
||||
// the service so RunTurn mints a fresh user-scoped token
|
||||
// per turn. The secret is the same SUPABASE_JWT_SECRET the
|
||||
// auth client uses to verify session cookies; we just sign
|
||||
// short-lived tokens with it.
|
||||
cfg.JWTSecret = []byte(jwtSecret)
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s, rls=per-user)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Per-user RLS auth (t-paliad-156): wire the JWT secret so
|
||||
// RunTurn mints a fresh user-scoped token and writes it to
|
||||
// <responseDir>/<turnID>.jwt for the claude pane to read.
|
||||
local.SetJWTAuth([]byte(jwtSecret), 0)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on, rls=per-user)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Reverse t-paliad-156. REVOKE is no-op-safe when the right isn't held.
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION paliad.can_see_project(uuid) FROM authenticated;
|
||||
|
||||
REVOKE SELECT ON paliad.users,
|
||||
paliad.projects,
|
||||
paliad.project_teams,
|
||||
paliad.deadlines,
|
||||
paliad.appointments,
|
||||
paliad.parties,
|
||||
paliad.partner_unit_members,
|
||||
paliad.project_partner_units,
|
||||
paliad.deadline_rules,
|
||||
paliad.courts,
|
||||
paliad.event_types,
|
||||
paliad.notes
|
||||
FROM authenticated;
|
||||
|
||||
REVOKE USAGE ON SCHEMA paliad FROM authenticated;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- t-paliad-156: Paliadin per-user RLS auth.
|
||||
--
|
||||
-- The Paliadin claude pane reaches the DB through the supabase MCP at
|
||||
-- ystudio.msbls.de, which authenticates as `supabase_admin` (BYPASSRLS).
|
||||
-- Without a role switch, every Paliadin query sees every user's data and
|
||||
-- the predicate `paliad.can_see_project(...)` becomes self-discipline,
|
||||
-- not enforcement (one missed predicate = a leak across users).
|
||||
--
|
||||
-- The fix is to wrap each paliad.* query in:
|
||||
--
|
||||
-- BEGIN;
|
||||
-- SET LOCAL ROLE authenticated;
|
||||
-- SET LOCAL request.jwt.claims = '{"sub":"<userID>","role":"authenticated"}';
|
||||
-- <query>
|
||||
-- ROLLBACK;
|
||||
--
|
||||
-- Once the role is switched, the existing RLS policies (defined for the
|
||||
-- `authenticated` role on every paliad table) fire automatically because
|
||||
-- `auth.uid()` resolves to the user from the claims. RLS becomes
|
||||
-- enforcement, not self-discipline.
|
||||
--
|
||||
-- For that to work, the `authenticated` role needs:
|
||||
-- * USAGE on the paliad schema (it doesn't, by default — paliad's
|
||||
-- existing Go services run with a connection that authenticates as
|
||||
-- `postgres`, which has BYPASSRLS too and didn't need an `authenticated`
|
||||
-- grant path).
|
||||
-- * SELECT on each table the Paliadin recipes read.
|
||||
-- * EXECUTE on paliad.can_see_project — belt-and-braces; the function
|
||||
-- is SECURITY DEFINER so the inner SELECTs already ignore the caller's
|
||||
-- role, but granting the call surface eliminates a potential
|
||||
-- "permission denied for function" error if a recipe ever falls back
|
||||
-- to the predicate after t-156.
|
||||
--
|
||||
-- The grants are read-only — Paliadin has no write path to the DB. Agent-
|
||||
-- suggested writes (t-paliad-161) go through `/api/paliadin/suggest/*`
|
||||
-- HTTP endpoints, which run server-side as the existing service-role
|
||||
-- connection and audit through paliad.approval_requests. This migration
|
||||
-- never gives the `authenticated` role write privileges on paliad.
|
||||
|
||||
GRANT USAGE ON SCHEMA paliad TO authenticated;
|
||||
|
||||
-- Read-only access to the tables Paliadin's SQL recipes touch. Listed
|
||||
-- explicitly (not GRANT SELECT ON ALL TABLES) so a future audit-only
|
||||
-- table accidentally landing in this schema doesn't open up to every
|
||||
-- authenticated user.
|
||||
GRANT SELECT ON paliad.users TO authenticated;
|
||||
GRANT SELECT ON paliad.projects TO authenticated;
|
||||
GRANT SELECT ON paliad.project_teams TO authenticated;
|
||||
GRANT SELECT ON paliad.deadlines TO authenticated;
|
||||
GRANT SELECT ON paliad.appointments TO authenticated;
|
||||
GRANT SELECT ON paliad.parties TO authenticated;
|
||||
GRANT SELECT ON paliad.partner_unit_members TO authenticated;
|
||||
GRANT SELECT ON paliad.project_partner_units TO authenticated;
|
||||
GRANT SELECT ON paliad.deadline_rules TO authenticated;
|
||||
GRANT SELECT ON paliad.courts TO authenticated;
|
||||
GRANT SELECT ON paliad.event_types TO authenticated;
|
||||
GRANT SELECT ON paliad.notes TO authenticated;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION paliad.can_see_project(uuid) TO authenticated;
|
||||
@@ -102,6 +102,18 @@ type LocalPaliadinService struct {
|
||||
sessionPrefix string
|
||||
responseDir string
|
||||
|
||||
// jwtSecret is paliad's SUPABASE_JWT_SECRET. When set, RunTurn mints
|
||||
// a per-turn user-scoped JWT, writes it next to the response file
|
||||
// (`<responseDir>/<turnID>.jwt`, chmod 600, removed in a deferred
|
||||
// cleanup), and embeds the path in the tmux envelope's `|jwt=<path>`
|
||||
// segment. The Paliadin skill reads it and uses
|
||||
// `SET LOCAL request.jwt.claims = …` before every paliad.* query so
|
||||
// RLS evaluates as the user (t-paliad-156). Empty → legacy envelope
|
||||
// shape, claude continues to query as supabase_admin (the pre-156
|
||||
// behaviour, kept so dev environments without the secret still boot).
|
||||
jwtSecret []byte
|
||||
jwtTTL time.Duration // 0 → DefaultPaliadinJWTTTL
|
||||
|
||||
// Cached pane targets per user-session, keyed by tmux session name.
|
||||
// A session entry maps to "session:window-idx"; cleared when the
|
||||
// pane dies or ResetSession is called for that user.
|
||||
@@ -120,6 +132,16 @@ type LocalPaliadinService struct {
|
||||
janitorOnce sync.Once
|
||||
}
|
||||
|
||||
// SetJWTAuth wires the per-turn JWT mint into LocalPaliadinService.
|
||||
// Called from cmd/server/main.go after the auth client has read
|
||||
// SUPABASE_JWT_SECRET; left as a setter (rather than a constructor arg)
|
||||
// so the existing NewLocalPaliadinService callsite stays compatible
|
||||
// while we ship per-user RLS auth (t-paliad-156).
|
||||
func (s *LocalPaliadinService) SetJWTAuth(secret []byte, ttl time.Duration) {
|
||||
s.jwtSecret = secret
|
||||
s.jwtTTL = ttl
|
||||
}
|
||||
|
||||
// IsOwner returns true when the given user_id corresponds to m's
|
||||
// account (the only Paliadin PoC user). Resolves via paliad.users.email
|
||||
// rather than caching a UUID so a DB rebuild that reassigns auth UUIDs
|
||||
@@ -358,14 +380,26 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
|
||||
return nil, fmt.Errorf("paliadin: mkdir response dir: %w", err)
|
||||
}
|
||||
|
||||
// Mint + write the per-turn JWT (t-paliad-156). Returns ("", nil)
|
||||
// when the secret isn't configured; in that case the envelope keeps
|
||||
// its legacy shape and claude queries as supabase_admin (pre-156
|
||||
// behaviour). The deferred cleanup runs before RunTurn returns so
|
||||
// the file never outlives the turn.
|
||||
jwtSegment, jwtCleanup, err := s.writeTurnJWT(req.UserID, turnID)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "jwt_mint_failed")
|
||||
return nil, fmt.Errorf("paliadin: write turn jwt: %w", err)
|
||||
}
|
||||
defer jwtCleanup()
|
||||
|
||||
// Send the framed prompt. The Paliadin skill at
|
||||
// ~/.claude/skills/paliadin/SKILL.md description-matches on this
|
||||
// envelope and writes the response to the per-turn file. The optional
|
||||
// [ctx …] prefix carries structured page context from the inline
|
||||
// widget (t-paliad-161); SKILL.md branches on it before answering.
|
||||
primer := s.buildPrimerIfFresh(ctx, isFresh, req)
|
||||
envelope := fmt.Sprintf("[PALIADIN:%s] %s%s%s",
|
||||
turnID, primer, req.Context.EnvelopePrefix(), sanitiseForTmux(req.UserMessage))
|
||||
envelope := fmt.Sprintf("[PALIADIN:%s%s] %s%s%s",
|
||||
turnID, jwtSegment, primer, req.Context.EnvelopePrefix(), sanitiseForTmux(req.UserMessage))
|
||||
if err := s.sendToPane(ctx, target, envelope); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "tmux_unresponsive")
|
||||
return nil, fmt.Errorf("%w: send prompt: %v", ErrTmuxUnavailable, err)
|
||||
@@ -411,6 +445,40 @@ func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*T
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeTurnJWT mints + persists the per-turn JWT (t-paliad-156). Returns
|
||||
// the envelope segment ("|jwt=<path>") to splice into the [PALIADIN:…]
|
||||
// header and a cleanup function the caller must defer. When the JWT
|
||||
// secret isn't configured, returns ("", noop) so RunTurn falls back to
|
||||
// the legacy envelope shape; SKILL.md detects the missing segment and
|
||||
// surfaces "JWT missing — paliad bug" rather than running queries as
|
||||
// service role.
|
||||
//
|
||||
// File layout: <responseDir>/<turnID>.jwt (chmod 600). Lives next to the
|
||||
// response file so the same `responseDir` cleanup window covers it; the
|
||||
// returned cleanup function removes it eagerly on RunTurn return so a
|
||||
// crashed turn doesn't leave the JWT lingering longer than the turn
|
||||
// itself.
|
||||
func (s *LocalPaliadinService) writeTurnJWT(userID, turnID uuid.UUID) (string, func(), error) {
|
||||
noop := func() {}
|
||||
if len(s.jwtSecret) == 0 {
|
||||
return "", noop, nil
|
||||
}
|
||||
tok, err := mintTurnJWT(userID, s.jwtTTL, s.jwtSecret)
|
||||
if err != nil {
|
||||
return "", noop, err
|
||||
}
|
||||
path := filepath.Join(s.responseDir, turnID.String()+".jwt")
|
||||
if err := os.WriteFile(path, []byte(tok), 0o600); err != nil {
|
||||
return "", noop, fmt.Errorf("paliadin: write %s: %w", path, err)
|
||||
}
|
||||
cleanup := func() {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("paliadin: cleanup turn jwt %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return "|jwt=" + path, cleanup, nil
|
||||
}
|
||||
|
||||
// ResetSession kills the user's tmux session entirely so the next
|
||||
// RunTurn boots a fresh claude pane. With skill-based persona load
|
||||
// (~/.claude/skills/paliadin/SKILL.md) the new pane re-acquires the
|
||||
|
||||
74
internal/services/paliadin_jwt.go
Normal file
74
internal/services/paliadin_jwt.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package services
|
||||
|
||||
// Per-turn supabase JWT minting for Paliadin (t-paliad-156).
|
||||
//
|
||||
// Each Paliadin turn carries a short-lived JWT scoped to the calling
|
||||
// user. The JWT is signed with paliad's existing SUPABASE_JWT_SECRET so
|
||||
// it has the same shape Supabase Auth itself issues — same claims, same
|
||||
// signature, same role. The shim hands it to the claude pane (via a
|
||||
// per-turn file at /tmp/paliadin-<turn>.jwt) and the SKILL.md teaches
|
||||
// claude to extract the claims and `SET LOCAL request.jwt.claims = …`
|
||||
// before every paliad.* query, which makes RLS evaluate as the user.
|
||||
//
|
||||
// TTL: short (default 2 min) — long enough to cover the 60 s shim
|
||||
// timeout plus generous slack for queueing, short enough that a leaked
|
||||
// JWT is uninteresting. Each turn mints fresh; nothing is cached.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrJWTSecretMissing signals that mintTurnJWT was called without the
|
||||
// SUPABASE_JWT_SECRET configured. paliad's auth layer fails fast on the
|
||||
// same condition at boot, but the per-turn mint path is reachable from
|
||||
// tests + the disabled stub, so we surface a typed error rather than
|
||||
// panicking.
|
||||
var ErrJWTSecretMissing = errors.New("paliadin: SUPABASE_JWT_SECRET not configured")
|
||||
|
||||
// DefaultPaliadinJWTTTL is the JWT lifetime when the caller doesn't
|
||||
// override. 2 minutes covers the shim's 120 s run-turn budget plus a
|
||||
// few seconds of buffer for SSH overhead and clock skew.
|
||||
const DefaultPaliadinJWTTTL = 2 * time.Minute
|
||||
|
||||
// mintTurnJWT signs a Supabase-shaped access token for the given user.
|
||||
// Claims:
|
||||
//
|
||||
// sub : userID — RLS reads this via auth.uid()
|
||||
// role : "authenticated" — required so SET LOCAL ROLE matches
|
||||
// aud : "authenticated" — Supabase convention
|
||||
// iss : "paliad/paliadin" — distinguishes from real GoTrue tokens in
|
||||
// audit traces; not validated by RLS
|
||||
// iat : now
|
||||
// exp : now + ttl
|
||||
//
|
||||
// Signed HS256 with SUPABASE_JWT_SECRET (same secret paliad already
|
||||
// verifies session cookies against in internal/auth.Client). The
|
||||
// returned string is a standard 3-segment JWT.
|
||||
func mintTurnJWT(userID uuid.UUID, ttl time.Duration, secret []byte) (string, error) {
|
||||
if len(secret) == 0 {
|
||||
return "", ErrJWTSecretMissing
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultPaliadinJWTTTL
|
||||
}
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID.String(),
|
||||
"role": "authenticated",
|
||||
"aud": "authenticated",
|
||||
"iss": "paliad/paliadin",
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := tok.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("paliadin: sign turn JWT: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
122
internal/services/paliadin_jwt_test.go
Normal file
122
internal/services/paliadin_jwt_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// JWT mint tests (t-paliad-156). Every Paliadin turn signs a per-user
|
||||
// access token using paliad's existing SUPABASE_JWT_SECRET. Tests pin
|
||||
// the claim shape (sub, role, aud, exp) that the SKILL.md SET LOCAL
|
||||
// recipe relies on, plus the hard failure when the secret is missing.
|
||||
|
||||
const testJWTSecret = "test-secret-not-real-paliad-paliadin-only"
|
||||
|
||||
func TestMintTurnJWT_ClaimShape(t *testing.T) {
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
tok, err := mintTurnJWT(uid, time.Minute, []byte(testJWTSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("mintTurnJWT: %v", err)
|
||||
}
|
||||
if strings.Count(tok, ".") != 2 {
|
||||
t.Errorf("token = %q; want 3-segment JWT", tok)
|
||||
}
|
||||
|
||||
parsed, err := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
|
||||
return []byte(testJWTSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok || !parsed.Valid {
|
||||
t.Fatalf("invalid claims: %T", parsed.Claims)
|
||||
}
|
||||
|
||||
if got, _ := claims["sub"].(string); got != uid.String() {
|
||||
t.Errorf("sub = %q; want %q", got, uid.String())
|
||||
}
|
||||
if got, _ := claims["role"].(string); got != "authenticated" {
|
||||
t.Errorf("role = %q; want authenticated (so SET LOCAL ROLE matches)", got)
|
||||
}
|
||||
if got, _ := claims["aud"].(string); got != "authenticated" {
|
||||
t.Errorf("aud = %q; want authenticated (Supabase convention)", got)
|
||||
}
|
||||
if got, _ := claims["iss"].(string); got != "paliad/paliadin" {
|
||||
t.Errorf("iss = %q; want paliad/paliadin (audit-trace marker)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintTurnJWT_HonoursTTL(t *testing.T) {
|
||||
uid := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
|
||||
tok, err := mintTurnJWT(uid, 30*time.Second, []byte(testJWTSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("mintTurnJWT: %v", err)
|
||||
}
|
||||
parsed, _ := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
|
||||
return []byte(testJWTSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
claims := parsed.Claims.(jwt.MapClaims)
|
||||
exp, err := claims.GetExpirationTime()
|
||||
if err != nil || exp == nil {
|
||||
t.Fatalf("exp claim missing: %v", err)
|
||||
}
|
||||
iat, err := claims.GetIssuedAt()
|
||||
if err != nil || iat == nil {
|
||||
t.Fatalf("iat claim missing: %v", err)
|
||||
}
|
||||
delta := exp.Sub(iat.Time)
|
||||
if delta < 25*time.Second || delta > 35*time.Second {
|
||||
t.Errorf("ttl ≈ %s; want ~30s", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintTurnJWT_DefaultTTL(t *testing.T) {
|
||||
uid := uuid.MustParse("cccccccc-1111-2222-3333-444444444444")
|
||||
// ttl=0 → DefaultPaliadinJWTTTL (2 minutes)
|
||||
tok, err := mintTurnJWT(uid, 0, []byte(testJWTSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("mintTurnJWT: %v", err)
|
||||
}
|
||||
parsed, _ := jwt.Parse(tok, func(t *jwt.Token) (any, error) {
|
||||
return []byte(testJWTSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
claims := parsed.Claims.(jwt.MapClaims)
|
||||
exp, _ := claims.GetExpirationTime()
|
||||
iat, _ := claims.GetIssuedAt()
|
||||
delta := exp.Sub(iat.Time)
|
||||
if delta != DefaultPaliadinJWTTTL {
|
||||
t.Errorf("default ttl = %s; want %s", delta, DefaultPaliadinJWTTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintTurnJWT_RejectsEmptySecret(t *testing.T) {
|
||||
uid := uuid.MustParse("dddddddd-1111-2222-3333-444444444444")
|
||||
_, err := mintTurnJWT(uid, time.Minute, nil)
|
||||
if !errors.Is(err, ErrJWTSecretMissing) {
|
||||
t.Errorf("err = %v; want ErrJWTSecretMissing", err)
|
||||
}
|
||||
_, err = mintTurnJWT(uid, time.Minute, []byte(""))
|
||||
if !errors.Is(err, ErrJWTSecretMissing) {
|
||||
t.Errorf("err = %v; want ErrJWTSecretMissing for empty slice", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintTurnJWT_SignatureRejectsWrongSecret(t *testing.T) {
|
||||
uid := uuid.MustParse("eeeeeeee-1111-2222-3333-444444444444")
|
||||
tok, err := mintTurnJWT(uid, time.Minute, []byte(testJWTSecret))
|
||||
if err != nil {
|
||||
t.Fatalf("mintTurnJWT: %v", err)
|
||||
}
|
||||
_, err = jwt.Parse(tok, func(t *jwt.Token) (any, error) {
|
||||
return []byte("wrong-secret"), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err == nil {
|
||||
t.Error("parse with wrong secret succeeded; want signature failure")
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,18 @@ type RemotePaliadinConfig struct {
|
||||
SSHKeyPath string // /tmp/paliadin-id_ed25519-<rand> (chmod 600)
|
||||
KnownHostsPath string // /tmp/paliadin-known_hosts
|
||||
SessionPrefix string // tmux session prefix; per-user session is "<prefix>-<userid8>"
|
||||
|
||||
// JWTSecret is paliad's SUPABASE_JWT_SECRET. RemotePaliadinService
|
||||
// uses it to mint per-turn user-scoped JWTs (t-paliad-156), which
|
||||
// the shim writes to a per-turn file the claude pane reads to set
|
||||
// `request.jwt.claims` before every paliad.* SQL query — so RLS
|
||||
// evaluates as the user instead of as service role. When empty the
|
||||
// service falls back to legacy 3-arg run-turn calls (no |jwt=…
|
||||
// envelope segment) so dev environments without the secret still
|
||||
// boot, at the cost of leaving Paliadin queries running as
|
||||
// supabase_admin.
|
||||
JWTSecret []byte
|
||||
JWTTTL time.Duration // 0 → DefaultPaliadinJWTTTL
|
||||
}
|
||||
|
||||
// RemotePaliadinService implements Paliadin against a remote
|
||||
@@ -185,7 +197,16 @@ func (s *RemotePaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*
|
||||
msg := primer + req.Context.EnvelopePrefix() + sanitiseForTmux(req.UserMessage)
|
||||
msgB64 := base64.StdEncoding.EncodeToString([]byte(msg))
|
||||
|
||||
body, err := s.callShim(ctx, "run-turn", session, turnID.String(), msgB64)
|
||||
// Mint a per-turn JWT scoped to req.UserID (t-paliad-156). The shim
|
||||
// writes the JWT to a per-turn file on mRiver, names it in the
|
||||
// envelope's `|jwt=<path>` segment, and SKILL.md teaches claude to
|
||||
// extract claims and `SET LOCAL request.jwt.claims = …` before each
|
||||
// paliad.* query — turning the visibility check into RLS enforcement
|
||||
// rather than skill-discipline. When the secret is unset (older
|
||||
// dev environments) we fall back to the legacy 3-arg run-turn so
|
||||
// the service still boots; SKILL.md detects the missing |jwt=…
|
||||
// segment and reports the bug rather than silently leaking.
|
||||
body, err := s.runTurnViaShim(ctx, session, turnID, req.UserID, msgB64)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifySSHError(err))
|
||||
return nil, err
|
||||
@@ -297,6 +318,30 @@ func (s *RemotePaliadinService) healthGate(ctx context.Context, session string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runTurnViaShim mints the per-turn JWT (t-paliad-156) and dispatches
|
||||
// the shim's run-turn verb. Two argv shapes are emitted depending on
|
||||
// whether the JWT secret is configured:
|
||||
//
|
||||
// JWT path (preferred): run-turn <session> <jwt-b64> <turn_id> <msg-b64>
|
||||
// legacy (fallback): run-turn <session> <turn_id> <msg-b64>
|
||||
//
|
||||
// The shim accepts both shapes for one release: a 4-arg run-turn
|
||||
// triggers the per-user RLS pipeline, while a 3-arg run-turn keeps the
|
||||
// pre-156 service-role behaviour for environments that haven't been
|
||||
// reconfigured yet. Once every paliad deploy carries SUPABASE_JWT_SECRET
|
||||
// AND mRiver carries the t-156 shim, the legacy branch can be removed.
|
||||
func (s *RemotePaliadinService) runTurnViaShim(ctx context.Context, session string, turnID, userID uuid.UUID, msgB64 string) ([]byte, error) {
|
||||
if len(s.cfg.JWTSecret) == 0 {
|
||||
return s.callShim(ctx, "run-turn", session, turnID.String(), msgB64)
|
||||
}
|
||||
tok, err := mintTurnJWT(userID, s.cfg.JWTTTL, s.cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("paliadin: mint turn jwt: %w", err)
|
||||
}
|
||||
jwtB64 := base64.StdEncoding.EncodeToString([]byte(tok))
|
||||
return s.callShim(ctx, "run-turn", session, jwtB64, turnID.String(), msgB64)
|
||||
}
|
||||
|
||||
// callShim runs `ssh <user>@<host> -- <verb> <args...>` against the
|
||||
// paliadin-shim. The shim's authorized_keys command= directive ensures
|
||||
// the verb + args are passed via $SSH_ORIGINAL_COMMAND regardless of
|
||||
|
||||
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -280,6 +282,88 @@ func TestCallShim_SSHArgvShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnViaShim_LegacyShapeWhenNoJWTSecret(t *testing.T) {
|
||||
// When JWTSecret is empty, runTurnViaShim must fall back to the
|
||||
// pre-156 3-arg run-turn shape so dev environments without the
|
||||
// secret still boot. The 4-arg shape is exclusively triggered by
|
||||
// having the secret configured.
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{SSHHost: "x"})
|
||||
var captured []string
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
captured = append([]string(nil), args...)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
turn := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||
if _, err := s.runTurnViaShim(context.Background(), "paliad-paliadin-aaaaaaaa", turn, uid, "bWFkZQ=="); err != nil {
|
||||
t.Fatalf("runTurnViaShim: %v", err)
|
||||
}
|
||||
want := []string{"run-turn", "paliad-paliadin-aaaaaaaa", turn.String(), "bWFkZQ=="}
|
||||
if len(captured) != len(want) {
|
||||
t.Fatalf("legacy callShim args = %v; want %v", captured, want)
|
||||
}
|
||||
for i := range want {
|
||||
if captured[i] != want[i] {
|
||||
t.Errorf("legacy callShim arg[%d] = %q; want %q", i, captured[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnViaShim_PerUserShapeWithJWTSecret(t *testing.T) {
|
||||
// With JWTSecret set, runTurnViaShim must mint a JWT and emit the
|
||||
// 4-arg shape: <session> <jwt-b64> <turn_id> <msg-b64>. The
|
||||
// resulting JWT must (a) be valid HS256-signed with the configured
|
||||
// secret, (b) carry sub=userID and role=authenticated so the SKILL.md
|
||||
// SET LOCAL recipe sets RLS up correctly.
|
||||
s := NewRemotePaliadinService(nil, nil, RemotePaliadinConfig{
|
||||
SSHHost: "x",
|
||||
JWTSecret: []byte(testJWTSecret),
|
||||
})
|
||||
var captured []string
|
||||
s.callShimHook = func(ctx context.Context, args ...string) ([]byte, error) {
|
||||
captured = append([]string(nil), args...)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
uid := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
|
||||
turn := uuid.MustParse("99999999-aaaa-bbbb-cccc-dddddddddddd")
|
||||
if _, err := s.runTurnViaShim(context.Background(), "paliad-paliadin-bbbbbbbb", turn, uid, "Zm9v"); err != nil {
|
||||
t.Fatalf("runTurnViaShim: %v", err)
|
||||
}
|
||||
if len(captured) != 5 {
|
||||
t.Fatalf("per-user callShim got %d args, want 5: %v", len(captured), captured)
|
||||
}
|
||||
if captured[0] != "run-turn" {
|
||||
t.Errorf("verb = %q; want run-turn", captured[0])
|
||||
}
|
||||
if captured[1] != "paliad-paliadin-bbbbbbbb" {
|
||||
t.Errorf("session = %q", captured[1])
|
||||
}
|
||||
// argv[2] is JWT-base64 — decode + parse to assert claims.
|
||||
jwtBytes, err := base64.StdEncoding.DecodeString(captured[2])
|
||||
if err != nil {
|
||||
t.Fatalf("captured[2] not valid base64: %v", err)
|
||||
}
|
||||
parsed, err := jwt.Parse(string(jwtBytes), func(t *jwt.Token) (any, error) {
|
||||
return []byte(testJWTSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
t.Fatalf("captured JWT failed signature: %v", err)
|
||||
}
|
||||
claims := parsed.Claims.(jwt.MapClaims)
|
||||
if got, _ := claims["sub"].(string); got != uid.String() {
|
||||
t.Errorf("JWT.sub = %q; want %q (per-user RLS gate broken)", got, uid.String())
|
||||
}
|
||||
if got, _ := claims["role"].(string); got != "authenticated" {
|
||||
t.Errorf("JWT.role = %q; want authenticated", got)
|
||||
}
|
||||
if captured[3] != turn.String() {
|
||||
t.Errorf("turn_id = %q; want %s", captured[3], turn.String())
|
||||
}
|
||||
if captured[4] != "Zm9v" {
|
||||
t.Errorf("msg-b64 = %q; want Zm9v", captured[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallShim_StderrSurfacesInError(t *testing.T) {
|
||||
// When the real exec path fails, callShim wraps stderr into the
|
||||
// returned error so classifySSHError can pattern-match. Simulate
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestTruncateForPrimer pins the per-side truncation contract used by
|
||||
@@ -285,3 +290,101 @@ func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
|
||||
t.Errorf("expected truncation marker, got tail: %q", got[len(got)-20:])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalPaliadin_WriteTurnJWT_NoSecretReturnsEmpty pins the legacy
|
||||
// fallback: when SUPABASE_JWT_SECRET isn't wired, RunTurn must skip the
|
||||
// `|jwt=…` envelope segment so the SKILL.md "JWT missing — paliad bug"
|
||||
// branch fires explicitly rather than the pane silently keeping
|
||||
// service-role visibility.
|
||||
func TestLocalPaliadin_WriteTurnJWT_NoSecretReturnsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewLocalPaliadinService(nil, nil, "test-prefix", dir)
|
||||
// jwtSecret left empty by construction.
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
turn := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||
|
||||
seg, cleanup, err := s.writeTurnJWT(uid, turn)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTurnJWT: %v", err)
|
||||
}
|
||||
if seg != "" {
|
||||
t.Errorf("envelope segment = %q; want empty when no secret", seg)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatalf("cleanup must be a no-op func, not nil")
|
||||
}
|
||||
cleanup() // must not panic
|
||||
// No file should have been created.
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 0 {
|
||||
t.Errorf("temp dir not empty: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalPaliadin_WriteTurnJWT_WritesAndCleansUp verifies the per-turn
|
||||
// JWT lifecycle: file written at <dir>/<turn>.jwt with chmod 600,
|
||||
// envelope segment populated, cleanup removes the file.
|
||||
func TestLocalPaliadin_WriteTurnJWT_WritesAndCleansUp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewLocalPaliadinService(nil, nil, "test-prefix", dir)
|
||||
s.SetJWTAuth([]byte(testJWTSecret), 0)
|
||||
|
||||
uid := uuid.MustParse("bbbbbbbb-1111-2222-3333-444444444444")
|
||||
turn := uuid.MustParse("22222222-3333-4444-5555-666666666666")
|
||||
|
||||
seg, cleanup, err := s.writeTurnJWT(uid, turn)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTurnJWT: %v", err)
|
||||
}
|
||||
wantSeg := "|jwt=" + filepath.Join(dir, turn.String()+".jwt")
|
||||
if seg != wantSeg {
|
||||
t.Errorf("segment = %q; want %q", seg, wantSeg)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, turn.String()+".jwt")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode != 0o600 {
|
||||
t.Errorf("file mode = %o; want 0o600 (token contents must not be world-readable)", mode)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
parsed, err := jwt.Parse(string(body), func(t *jwt.Token) (any, error) {
|
||||
return []byte(testJWTSecret), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
t.Fatalf("parse jwt from file: %v", err)
|
||||
}
|
||||
claims := parsed.Claims.(jwt.MapClaims)
|
||||
if got, _ := claims["sub"].(string); got != uid.String() {
|
||||
t.Errorf("file JWT sub = %q; want %q", got, uid.String())
|
||||
}
|
||||
|
||||
cleanup()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("cleanup must remove the JWT file; stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalPaliadin_WriteTurnJWT_CleanupIdempotent verifies cleanup
|
||||
// doesn't error if the file is already gone (e.g. caller ran cleanup
|
||||
// twice, or another process raced ahead).
|
||||
func TestLocalPaliadin_WriteTurnJWT_CleanupIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewLocalPaliadinService(nil, nil, "test-prefix", dir)
|
||||
s.SetJWTAuth([]byte(testJWTSecret), 0)
|
||||
|
||||
uid := uuid.New()
|
||||
turn := uuid.New()
|
||||
_, cleanup, err := s.writeTurnJWT(uid, turn)
|
||||
if err != nil {
|
||||
t.Fatalf("writeTurnJWT: %v", err)
|
||||
}
|
||||
// Manually remove the file so cleanup hits the not-exist branch.
|
||||
_ = os.Remove(filepath.Join(dir, turn.String()+".jwt"))
|
||||
cleanup() // must not panic, must not log a confusing error
|
||||
}
|
||||
|
||||
@@ -11,9 +11,21 @@
|
||||
# Verbs (every verb takes the tmux session name as the first positional
|
||||
# argument; per-user sessions are created on demand):
|
||||
#
|
||||
# health <session> -> "ok" iff tmux + claude reachable
|
||||
# run-turn <session> <uuid> <msg-base64> -> send framed prompt, poll, return
|
||||
# reset <session> -> kill the session entirely
|
||||
# health <session> -> "ok" iff tmux + claude reachable
|
||||
# run-turn <session> <jwt-b64> <uuid> <msg-base64> -> per-user RLS path (t-paliad-156)
|
||||
# run-turn <session> <uuid> <msg-base64> -> legacy 3-arg path (no JWT, supabase_admin RLS bypass)
|
||||
# reset <session> -> kill the session entirely
|
||||
#
|
||||
# The 4-arg run-turn (t-paliad-156) carries a per-turn user-scoped JWT
|
||||
# alongside the message. The shim writes the JWT to a per-turn file at
|
||||
# /tmp/paliadin-<uuid>.jwt (chmod 600, removed via trap on EXIT) and
|
||||
# splices `|jwt=<path>` into the [PALIADIN:<uuid>…] envelope so the
|
||||
# Paliadin skill can read claims and `SET LOCAL request.jwt.claims = …`
|
||||
# before every paliad.* SQL query — turning visibility from skill-
|
||||
# discipline into RLS enforcement.
|
||||
#
|
||||
# The 3-arg run-turn (legacy) is kept for one release so paliad
|
||||
# deployments without SUPABASE_JWT_SECRET wired through still boot.
|
||||
#
|
||||
# The persona + response protocol live in the Paliadin skill at
|
||||
# ~/.claude/skills/paliadin/SKILL.md (see scripts/skills/paliadin/SKILL.md
|
||||
@@ -168,18 +180,53 @@ case "$verb" in
|
||||
;;
|
||||
|
||||
run-turn)
|
||||
# $1 = session, $2 = turn_id (UUID), $3 = base64-encoded user message.
|
||||
# 4-arg shape (t-paliad-156): <session> <jwt-b64> <turn_id> <msg-b64>
|
||||
# 3-arg shape (legacy): <session> <turn_id> <msg-b64>
|
||||
#
|
||||
# Disambiguation: argv[2] is a JWT-base64 iff argv[3] looks like a
|
||||
# UUID. Otherwise we're on the legacy path and argv[2] is the UUID.
|
||||
session=$(require_session)
|
||||
turn_id="${argv[2]:-}"
|
||||
jwt_b64=""
|
||||
if [[ -n "${argv[3]:-}" && "${argv[3]}" =~ $TURN_ID_RE ]]; then
|
||||
# Per-user RLS path. argv[2] is JWT-base64, argv[3] is turn_id, argv[4] is msg-b64.
|
||||
jwt_b64="${argv[2]:-}"
|
||||
turn_id="${argv[3]}"
|
||||
msg_b64="${argv[4]:-}"
|
||||
else
|
||||
# Legacy path. argv[2] is turn_id, argv[3] is msg-b64.
|
||||
turn_id="${argv[2]:-}"
|
||||
msg_b64="${argv[3]:-}"
|
||||
fi
|
||||
|
||||
if [[ ! "$turn_id" =~ $TURN_ID_RE ]]; then
|
||||
log_err "run-turn: bad turn_id"; exit 2
|
||||
fi
|
||||
if [[ -z "${argv[3]:-}" ]]; then
|
||||
if [[ -z "$msg_b64" ]]; then
|
||||
log_err "run-turn: missing message"; exit 2
|
||||
fi
|
||||
if ! msg=$(printf '%s' "${argv[3]}" | base64 -d 2>/dev/null); then
|
||||
if ! msg=$(printf '%s' "$msg_b64" | base64 -d 2>/dev/null); then
|
||||
log_err "run-turn: invalid base64 message"; exit 2
|
||||
fi
|
||||
|
||||
# Write the per-turn JWT to disk and arrange for cleanup. The skill
|
||||
# reads it through the `|jwt=<path>` envelope segment and uses the
|
||||
# claims to gate every paliad.* SQL query through RLS. Cleanup
|
||||
# happens on every exit path (including timeout, error, kill); the
|
||||
# turn budget is short enough that lingering files would be a leak.
|
||||
jwt_segment=""
|
||||
if [[ -n "$jwt_b64" ]]; then
|
||||
jwt_path="$RESPONSE_DIR/$turn_id.jwt"
|
||||
if ! jwt=$(printf '%s' "$jwt_b64" | base64 -d 2>/dev/null); then
|
||||
log_err "run-turn: invalid base64 jwt"; exit 2
|
||||
fi
|
||||
# umask 077 + explicit chmod for clarity; the file holds a token
|
||||
# that's good for one turn but trivially abused if leaked.
|
||||
( umask 077; printf '%s' "$jwt" > "$jwt_path" )
|
||||
chmod 600 "$jwt_path"
|
||||
trap 'rm -f "$jwt_path"' EXIT
|
||||
jwt_segment="|jwt=$jwt_path"
|
||||
fi
|
||||
|
||||
target=$(ensure_pane "$session")
|
||||
out="$RESPONSE_DIR/$turn_id.txt"
|
||||
rm -f "$out"
|
||||
@@ -187,8 +234,9 @@ case "$verb" in
|
||||
# Envelope. The Paliadin skill (~/.claude/skills/paliadin/SKILL.md)
|
||||
# description-matches on this exact prefix, so Claude routes to the
|
||||
# skill on every turn regardless of conversation state — surviving
|
||||
# /clear, fresh sessions, and pane restarts.
|
||||
send_to_pane "$target" "[PALIADIN:$turn_id] $msg"
|
||||
# /clear, fresh sessions, and pane restarts. The optional `|jwt=…`
|
||||
# segment (t-paliad-156) carries the per-turn JWT path.
|
||||
send_to_pane "$target" "[PALIADIN:$turn_id$jwt_segment] $msg"
|
||||
|
||||
# Poll for the response file. Same shape as Go pollForResponse
|
||||
# (paliadin.go). Settle delay so we don't read mid-flush.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: paliadin
|
||||
description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the polling Go service can return it to the user. Trigger on the literal `[PALIADIN:` prefix, even when m's question is short ("Hey", "wer bin ich?") and looks like normal chat — the prefix is the contract, not the question content. Persona: m's Patentpraxis-Plattform-Assistent — terse, juristisch präzise German, no emojis, every concrete claim backed by a tool-call.
|
||||
description: Use this skill whenever a user message arrives prefixed with `[PALIADIN:<uuid>]` or `[PALIADIN:<uuid>|jwt=<path>]` — that prefix means the request comes from the Paliad backend and a Markdown answer must be written to `/tmp/paliadin/<uuid>.txt` (with a `[paliadin-meta]` trailer) so the polling Go service can return it to the user. Trigger on the literal `[PALIADIN:` prefix, even when m's question is short ("Hey", "wer bin ich?") and looks like normal chat — the prefix is the contract, not the question content. The `|jwt=<path>` segment (t-paliad-156) marks per-user RLS auth: every paliad.* query MUST wrap in BEGIN/SET LOCAL ROLE authenticated/SET LOCAL request.jwt.claims/ROLLBACK so RLS sees the user, not service role. Persona: m's Patentpraxis-Plattform-Assistent — terse, juristisch präzise German, no emojis, every concrete claim backed by a tool-call.
|
||||
---
|
||||
|
||||
# Paliadin
|
||||
@@ -9,12 +9,18 @@ You are the in-app AI assistant inside **Paliad**, m's Patentpraxis-Plattform f
|
||||
|
||||
## Quick start — one turn
|
||||
|
||||
Every Paliad request looks like:
|
||||
Every Paliad request looks like one of:
|
||||
|
||||
```
|
||||
[PALIADIN:<turn_id>] [ctx route=… entity=…:<id> selection="…" view=… filter="…"] <Frage>
|
||||
[PALIADIN:<turn_id>|jwt=<path>] [ctx …] <Frage>
|
||||
```
|
||||
|
||||
The `|jwt=<path>` segment (t-paliad-156) is the **per-user RLS gate**:
|
||||
when present, every paliad.* SQL query MUST run under that user's
|
||||
identity, not service role. See *Per-user RLS auth* below — this is
|
||||
load-bearing for privacy.
|
||||
|
||||
The `[ctx …]` block is **optional** — present only when the request comes
|
||||
from the inline widget (t-paliad-161); the standalone `/paliadin` page omits
|
||||
it. When present, treat its contents as **authoritative context**, not as
|
||||
@@ -23,7 +29,7 @@ ask which project / deadline / appointment they mean.
|
||||
|
||||
Per turn:
|
||||
|
||||
1. **Extract `<turn_id>`** from the prefix.
|
||||
1. **Extract `<turn_id>`** from the prefix. If the prefix contains `|jwt=<path>`, also extract `<path>` and load the claims (see *Per-user RLS auth*).
|
||||
2. **Parse `[ctx …]`** if present. See *Context envelope* below.
|
||||
3. **Research** with tools (max 1–3 calls — backend timeout is 60s). See [references/sql-recipes.md](references/sql-recipes.md) **before any project/deadline/court/glossary/UPC lookup**.
|
||||
4. **Write the file** with `Write("/tmp/paliadin/<turn_id>.txt", …)` containing the Markdown answer + `[paliadin-meta]` trailer.
|
||||
@@ -31,6 +37,65 @@ Per turn:
|
||||
|
||||
> Skip every greeting / preamble in the chat pane. The file is the user-visible artefact; everything else is irrelevant.
|
||||
|
||||
## Per-user RLS auth (`|jwt=<path>`)
|
||||
|
||||
When the envelope contains `|jwt=<path>`, paliad has minted a per-turn
|
||||
JWT scoped to the calling user. The file at `<path>` contains a
|
||||
3-segment JWT (`header.payload.signature`); we only need the **payload
|
||||
claims** (sub, role, aud, exp).
|
||||
|
||||
**Extract claims once at the top of the turn**, then reuse the resulting
|
||||
JSON for every paliad.* query:
|
||||
|
||||
```bash
|
||||
JWT_FILE="<path>" # the value after |jwt= in the envelope
|
||||
PALIADIN_CLAIMS=$(jq -R 'split(".") | .[1] | gsub("-"; "+") | gsub("_"; "/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d' "$JWT_FILE")
|
||||
echo "$PALIADIN_CLAIMS" # → e.g. "{\"sub\":\"…\",\"role\":\"authenticated\",\"aud\":\"authenticated\",\"exp\":…}"
|
||||
```
|
||||
|
||||
Then **wrap every `paliad.*` query** in this 3-line prelude (use the
|
||||
`mcp__supabase__execute_sql` tool — wrapping is just SQL, no MCP-level
|
||||
auth involved):
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<paste $PALIADIN_CLAIMS verbatim, single-quoted>';
|
||||
|
||||
-- your actual query (no can_see_project predicate needed — RLS handles it)
|
||||
SELECT id, kind, label, status FROM paliad.projects ORDER BY path LIMIT 25;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
`ROLLBACK` (not `COMMIT`) is intentional: Paliadin is read-only, and
|
||||
rolling back is harmless — `SET LOCAL` is scoped to the transaction, so
|
||||
the role + claims are gone the moment the transaction ends regardless of
|
||||
COMMIT/ROLLBACK. Using ROLLBACK signals intent and protects against any
|
||||
accidental DML.
|
||||
|
||||
### Hard rules for per-user RLS
|
||||
|
||||
1. **Never query paliad.* without the wrapper.** The `mcp__supabase__execute_sql` tool authenticates as `supabase_admin` (BYPASSRLS); a raw query sees every user's data and breaks the privacy contract m built this whole pipeline to enforce.
|
||||
2. **`data.*` queries (UPC case law, recipe 8) DO NOT need the wrapper** — those are firm-wide reference data, not per-user. Run them as supabase_admin (the default).
|
||||
3. **JWT missing or unreadable**: when the envelope has no `|jwt=<path>` segment, OR the file at `<path>` is missing/empty/malformed, write this answer and stop:
|
||||
|
||||
```
|
||||
Paliadin kann gerade nicht auf deine Daten zugreifen — der per-Turn-JWT fehlt. Bitte paliad neu deployen oder den Admin um eine Diagnose bitten (t-paliad-156 Auth-Pipeline).
|
||||
|
||||
---
|
||||
[paliadin-meta]
|
||||
used_tools:
|
||||
rows_seen:
|
||||
classifier_tag: meta
|
||||
[/paliadin-meta]
|
||||
```
|
||||
|
||||
Do **not** fall back to a query without the wrapper — that defeats the privacy gate.
|
||||
|
||||
4. **No `can_see_project(project_id)` predicate in queries.** RLS now enforces visibility automatically because `auth.uid()` resolves to the user from the claims. Keeping the old predicate is harmless but obscures the actual gate.
|
||||
5. **The JWT TTL is 2 minutes.** If a query hits a `JWT expired` error, that means the turn took longer than expected; surface it honestly rather than retrying with a stale token.
|
||||
|
||||
## Crash-recovery primer (`[primer …][/primer]`)
|
||||
|
||||
When a tmux pane on mRiver was killed (reboot, OOM, manual `tmux
|
||||
@@ -217,7 +282,7 @@ curl -s -X POST http://localhost:8080/api/paliadin/suggest/deadline \
|
||||
1. **Keine Erfindungen.** Liefert ein Tool nichts, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
|
||||
2. **Jede konkrete Aussage über m's Arbeit MUSS aus einem Tool-Call der aktuellen Antwort kommen.** Erinnerung an frühere Gespräche reicht nicht — Daten ändern sich.
|
||||
3. **Read-only.** Schreibe nichts in die DB. Wenn m etwas ändern will, sag wo in Paliad.
|
||||
4. **Visibility-Gate respektieren.** Auch wenn m global_admin ist: jede projekt-bezogene Abfrage MUSS `paliad.can_see_project(project_id)` enthalten.
|
||||
4. **Per-user RLS-Gate respektieren** (t-paliad-156). Wenn die Envelope `|jwt=<path>` trägt: jede `paliad.*`-Query MUSS im `BEGIN; SET LOCAL ROLE authenticated; SET LOCAL request.jwt.claims = '…'; <q>; ROLLBACK;`-Wrapper laufen — siehe *Per-user RLS auth* oben. Eine Roh-Abfrage ohne Wrapper sieht alle User-Daten (BYPASSRLS) und bricht den Privacy-Contract.
|
||||
5. **Nicht über andere User spekulieren** — frag nach Projekt-ID/Slug, selbst wenn m sie namentlich erwähnt.
|
||||
6. **Niemals auf `psql`, `curl PostgREST`, `nix-shell` oder andere DB-Fallbacks ausweichen.** Die einzig zulässige DB-Quelle ist `mcp__supabase__execute_sql` (project-scoped MCP). Wenn dieser Tool-Aufruf nicht verfügbar ist, schreibe sofort: *"DB nicht erreichbar — bitte paliad neu deployen oder PALIADIN_REMOTE_CWD prüfen."* mit `classifier_tag: meta`. Niemals 60+ Sekunden im Fallback-Tanz verbringen — der Backend-Timeout schlägt sonst zu, bevor du eine Antwort schreibst.
|
||||
|
||||
|
||||
@@ -2,99 +2,160 @@
|
||||
|
||||
Read this file **before any project / deadline / appointment / court / glossary / deadline-rule / UPC-judgment lookup**. Every query goes through the Supabase MCP via `mcp__supabase__execute_sql`. Two schemas in the same physical DB:
|
||||
|
||||
- `paliad.*` — Patentpraxis-Daten (projects, deadlines, appointments, parties, courts, deadline_rules, users)
|
||||
- `data.*` — youpc.org UPC case law (judgments, headnotes, knowledge graph)
|
||||
- `paliad.*` — Patentpraxis-Daten (projects, deadlines, appointments, parties, courts, deadline_rules, users) — **per-user RLS-gated**
|
||||
- `data.*` — youpc.org UPC case law (judgments, headnotes, knowledge graph) — **firm-wide reference, no per-user gating**
|
||||
|
||||
Every project-scoped query MUST include `paliad.can_see_project(project_id)` — even when m is global_admin (see SKILL.md rule 4).
|
||||
## Per-user RLS wrapper (t-paliad-156)
|
||||
|
||||
When the envelope contains `|jwt=<path>` (it does on every recent paliad
|
||||
deploy), every `paliad.*` query MUST run inside this 3-line prelude.
|
||||
Read SKILL.md → *Per-user RLS auth* for the JWT-claims extraction
|
||||
one-liner; the resulting JSON goes into `SET LOCAL request.jwt.claims`.
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims JSON, single-quoted>';
|
||||
|
||||
-- recipe body
|
||||
…
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
`ROLLBACK` over `COMMIT` is intentional — Paliadin is read-only and
|
||||
SET LOCAL is tx-scoped, so rollback is harmless and intent-revealing.
|
||||
**Never run `paliad.*` queries outside this wrapper.** The MCP
|
||||
authenticates as `supabase_admin` (BYPASSRLS); a raw query sees every
|
||||
user's data and breaks the privacy contract.
|
||||
|
||||
`data.*` queries (recipe 8) **do not** need the wrapper — UPC case law
|
||||
is firm-wide reference data, no per-user gating. Run them as the
|
||||
default supabase_admin role.
|
||||
|
||||
## 1. whats_on_my_plate — Dashboard-Übersicht
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims>';
|
||||
|
||||
SELECT
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date < current_date) AS overdue,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending' AND d.due_date = current_date) AS today,
|
||||
(SELECT count(*) FROM paliad.deadlines d
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND d.status = 'pending'
|
||||
AND d.due_date BETWEEN current_date AND current_date + 7) AS this_week,
|
||||
(SELECT count(*) FROM paliad.appointments a
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at::date = current_date) AS appointments_today;
|
||||
(SELECT count(*) FROM paliad.deadlines
|
||||
WHERE status = 'pending' AND due_date < current_date) AS overdue,
|
||||
(SELECT count(*) FROM paliad.deadlines
|
||||
WHERE status = 'pending' AND due_date = current_date) AS today,
|
||||
(SELECT count(*) FROM paliad.deadlines
|
||||
WHERE status = 'pending'
|
||||
AND due_date BETWEEN current_date AND current_date + 7) AS this_week,
|
||||
(SELECT count(*) FROM paliad.appointments
|
||||
WHERE start_at::date = current_date) AS appointments_today;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 2. list_my_projects
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims>';
|
||||
|
||||
SELECT id, kind, label, status, parent_id, path
|
||||
FROM paliad.projects
|
||||
WHERE paliad.can_see_project(id)
|
||||
AND status = 'active'
|
||||
WHERE status = 'active'
|
||||
ORDER BY path
|
||||
LIMIT 25;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 3. get_project_detail (per slug oder id)
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims>';
|
||||
|
||||
SELECT p.*,
|
||||
(SELECT json_agg(d ORDER BY d.due_date)
|
||||
FROM paliad.deadlines d WHERE d.project_id = p.id
|
||||
AND paliad.can_see_project(d.project_id)) AS deadlines,
|
||||
FROM paliad.deadlines d WHERE d.project_id = p.id) AS deadlines,
|
||||
(SELECT json_agg(a ORDER BY a.start_at)
|
||||
FROM paliad.appointments a WHERE a.project_id = p.id
|
||||
AND paliad.can_see_project(a.project_id)) AS appointments,
|
||||
FROM paliad.appointments a WHERE a.project_id = p.id) AS appointments,
|
||||
(SELECT json_agg(pa) FROM paliad.parties pa WHERE pa.project_id = p.id) AS parties
|
||||
FROM paliad.projects p
|
||||
WHERE paliad.can_see_project(p.id)
|
||||
AND (p.id::text = '<UUID>' OR p.slug = '<slug>')
|
||||
WHERE p.id::text = '<UUID>' OR p.slug = '<slug>'
|
||||
LIMIT 1;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 4. search_my_deadlines (status / Datum / Projekt)
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims>';
|
||||
|
||||
SELECT d.id, d.title, d.due_date, d.status, p.label AS project_label, d.event_id
|
||||
FROM paliad.deadlines d
|
||||
JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE paliad.can_see_project(d.project_id)
|
||||
AND ($status::text IS NULL OR d.status = $status)
|
||||
WHERE ($status::text IS NULL OR d.status = $status)
|
||||
AND ($due_after::date IS NULL OR d.due_date >= $due_after)
|
||||
AND ($due_before::date IS NULL OR d.due_date <= $due_before)
|
||||
ORDER BY d.due_date ASC
|
||||
LIMIT 25;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 5. list_my_appointments (Zeitfenster)
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims>';
|
||||
|
||||
SELECT a.id, a.title, a.start_at, a.end_at, a.location, p.label AS project_label
|
||||
FROM paliad.appointments a
|
||||
LEFT JOIN paliad.projects p ON p.id = a.project_id
|
||||
WHERE (a.project_id IS NULL OR paliad.can_see_project(a.project_id))
|
||||
AND a.start_at >= $from
|
||||
WHERE a.start_at >= $from
|
||||
AND a.start_at <= $to
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 25;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 6. lookup_court (firm-wide reference)
|
||||
|
||||
Courts are firm-wide reference; the wrapper is still recommended for
|
||||
consistency (and so the recipe template stays uniform across paliad.*
|
||||
recipes), but RLS on `paliad.courts` allows authenticated to read every
|
||||
row regardless.
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims>';
|
||||
|
||||
SELECT c.slug, c.name, c.country, c.kind, c.address
|
||||
FROM paliad.courts c
|
||||
WHERE c.name ILIKE '%' || $q || '%'
|
||||
OR c.slug ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(c.name, $q) DESC
|
||||
LIMIT 10;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 7. lookup_deadline_rule (Fristenrechner-Konzepte)
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
SET LOCAL ROLE authenticated;
|
||||
SET LOCAL request.jwt.claims = '<claims>';
|
||||
|
||||
SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
|
||||
r.deadline_text_en, r.legal_source, r.deadline_notes, r.deadline_notes_en
|
||||
FROM paliad.deadline_rules r
|
||||
@@ -103,10 +164,16 @@ SELECT r.rule_code, r.concept_label, r.trigger_event, r.deadline_text,
|
||||
OR r.legal_source ILIKE '%' || $q || '%'
|
||||
ORDER BY similarity(r.concept_label, $q) DESC
|
||||
LIMIT 5;
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 8. lookup_youpc_case (UPC-Rechtsprechung — cross-schema)
|
||||
|
||||
**No wrapper.** UPC case law is firm-wide reference data; the `data`
|
||||
schema isn't part of paliad's per-user gate. Run as the default
|
||||
supabase_admin role.
|
||||
|
||||
```sql
|
||||
SELECT j.node_id, j.upc_number, j.court_division, j.judgment_type,
|
||||
j.proceedings_type, j.decision_date, j.headnote_summary,
|
||||
@@ -132,3 +199,9 @@ SELECT content
|
||||
## Glossar — keine SQL-Tabelle
|
||||
|
||||
Der Patent-Glossar lebt statisch in `internal/handlers/glossary.go` (JSON beim Boot geladen). Für reine Begriffsfragen reicht dein Wissen + optional Cross-Check via `paliad.deadline_rules.legal_source`.
|
||||
|
||||
## Why no `paliad.can_see_project(...)` predicate?
|
||||
|
||||
Pre-156 every paliad.* recipe ended with `WHERE paliad.can_see_project(project_id)`. That predicate was self-discipline — the MCP ran as service role with BYPASSRLS, so the function call was the only thing keeping queries scoped. One forgotten predicate = a leak across users.
|
||||
|
||||
Post-156 the role switch + claims feed RLS directly. `auth.uid()` resolves to the user from `request.jwt.claims.sub`; the policies on `paliad.projects`, `paliad.deadlines`, `paliad.appointments` etc. all already gate on `paliad.can_see_project(project_id)` themselves. The predicate-in-query layer is redundant once the role layer is in place. RLS is enforcement; the predicate was self-discipline.
|
||||
|
||||
Reference in New Issue
Block a user