-
Notifications
You must be signed in to change notification settings - Fork 6
feat: implement PAT token encryption at rest #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "permissions": { | ||
| "allow": [ | ||
| "Bash(docker compose up *)", | ||
| "Bash(curl -s http://localhost:8090/health)", | ||
| "Bash(go build *)", | ||
| "Bash(go test *)", | ||
| "Bash(docker compose *)", | ||
| "Bash(go run *)", | ||
| "Bash(gh pr *)", | ||
| "WebFetch(domain:api.github.com)" | ||
| ] | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # Langship Environment Configuration | ||
| # Copy this to .env.local or set these as environment variables | ||
|
|
||
| # === CRITICAL: Encryption Key === | ||
| # Master key for sealing all sensitive credentials at rest (PAT tokens, AWS keys, GCP service accounts, KV secrets). | ||
| # - Any non-empty string (will be hashed to 32 bytes) | ||
| # - In production: use a secure key store (Vault, cloud KMS) | ||
| # - If lost, all sealed credentials become unrecoverable | ||
| # - Example: openssl rand -hex 32 | ||
| FLOW_SECRET_KEY=your-secret-key-change-this-in-production | ||
|
|
||
| # === API Server === | ||
| FLOW_ADDR=:8090 | ||
| FLOW_CORS_ORIGINS=http://localhost:3000 | ||
| FLOW_PUBLIC_URL=http://localhost:8090 | ||
|
|
||
| # === Database === | ||
| MONGO_URI=mongodb://localhost:27017 | ||
| MONGO_DB=flow | ||
|
|
||
| # === Orchestration (Restate) === | ||
| RESTATE_INGRESS_URL=http://localhost:8081 | ||
| RESTATE_ADMIN_URL=http://localhost:9070 | ||
| RESTATE_SERVICE_ADDR=:9080 | ||
| RESTATE_DEPLOYMENT_URI=http://localhost:9080 | ||
|
|
||
| # === Build (BuildKit) === | ||
| BUILDKIT_HOST=tcp://127.0.0.1:1234 | ||
|
|
||
| # === Storage (MinIO / S3) === | ||
| MINIO_ENDPOINT=127.0.0.1:9000 | ||
| MINIO_ACCESS_KEY=minio | ||
| MINIO_SECRET_KEY=minio12345 | ||
| MINIO_BUCKET=flow-logs | ||
| MINIO_USE_SSL=false |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ import ( | |
| "github.com/lyzrai/flow/pkg/executors" | ||
| "github.com/lyzrai/flow/pkg/logstore" | ||
| "github.com/lyzrai/flow/pkg/orchestrator" | ||
| "github.com/lyzrai/flow/pkg/secrets" | ||
| "github.com/lyzrai/flow/pkg/storage" | ||
| ) | ||
|
|
||
|
|
@@ -133,6 +134,15 @@ func serve() int { | |
| }() | ||
| slog.Info("mongo connected", slog.String("db", mongoDB)) | ||
|
|
||
| // FLOW_SECRET_KEY is required for credential encryption. Check early so | ||
| // the failure is explicit rather than surfacing per-request. | ||
| if !secrets.IsConfigured() { | ||
| slog.Error("FLOW_SECRET_KEY is not set, exiting", | ||
| slog.String("hint", "set FLOW_SECRET_KEY to any non-empty value for encryption of PAT tokens, AWS keys, GCP service accounts, and KV secrets"), | ||
| ) | ||
| return 1 | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This startup check is a breaking change for anyone currently running the service without FLOW_SECRET_KEY set (i.e. deployments that predated the secrets package). The README update documents the variable, but there is no migration notice or graceful degradation path — the service simply refuses to start. If this is intentional and the policy is "no key, no start", that should be explicit in the PR description and ideally in a CHANGELOG or migration guide. If there are existing deployments with unsealed agents, operators need to know they must set the key before upgrading, not discover it from a crash on deploy. |
||
| // MinIO is optional — when MINIO_ENDPOINT is unset we skip log | ||
| // archiving. Live SSE log streaming still works regardless. | ||
| var logs logstore.Store | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -174,11 +174,14 @@ func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) { | |
| Name: name, | ||
| RepoURL: repo, | ||
| Ref: ref, | ||
| PAT: strings.TrimSpace(body.PAT), | ||
| AuthStatus: storage.AuthUntested, | ||
| CreatedAt: now, | ||
| UpdatedAt: now, | ||
| } | ||
| if err := a.SetPAT(strings.TrimSpace(body.PAT)); err != nil { | ||
| writeError(w, http.StatusInternalServerError, err) | ||
| return | ||
| } | ||
| if err := s.agents.Create(r.Context(), a); err != nil { | ||
| writeError(w, http.StatusInternalServerError, err) | ||
| return | ||
|
|
@@ -202,9 +205,17 @@ func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) { | |
| // dangling hooks pointing at a dead agent ID. | ||
| if a, err := s.agents.Get(r.Context(), id); err == nil && a.WebhookID != 0 { | ||
| if repo, perr := github.ParseRepo(a.RepoURL); perr == nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decrypt failure here is silently swallowed — if GetPAT() returns an error, the webhook is left installed and the agent is deleted without cleanup. Either propagate the error (return 500 before deleting) or log it explicitly so the leak is at least visible in observability tooling. |
||
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) | ||
| _ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID) | ||
| cancel() | ||
| pat, err := a.GetPAT() | ||
| if err != nil { | ||
| slog.WarnContext(r.Context(), "delete_agent_pat_decrypt_failed", | ||
| slog.String("agent_id", id), | ||
| slog.Any("error", err)) | ||
| } | ||
| if pat != "" { | ||
| ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) | ||
|
BrawlerXull marked this conversation as resolved.
|
||
| _ = github.NewClient(pat).UninstallWebhook(ctx, repo, a.WebhookID) | ||
| cancel() | ||
| } | ||
| } | ||
| } | ||
| if err := s.agents.Delete(r.Context(), id); err != nil { | ||
|
|
@@ -223,14 +234,22 @@ func (s *Server) handleTestAgentAuth(w http.ResponseWriter, r *http.Request) { | |
| writeStorageErr(w, err, "agent not found") | ||
| return | ||
| } | ||
| pat, err := a.GetPAT() | ||
| if err != nil { | ||
| slog.ErrorContext(r.Context(), "test_auth_pat_decrypt_failed", | ||
| slog.String("agent_id", id), | ||
| slog.Any("error", err)) | ||
| writeError(w, http.StatusInternalServerError, errors.New("failed to retrieve agent credentials")) | ||
| return | ||
| } | ||
| repo, err := github.ParseRepo(a.RepoURL) | ||
| if err != nil { | ||
| writeError(w, http.StatusBadRequest, err) | ||
| return | ||
| } | ||
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) | ||
| defer cancel() | ||
| authErr := github.NewClient(a.PAT).TestAuth(ctx, repo) | ||
| authErr := github.NewClient(pat).TestAuth(ctx, repo) | ||
|
|
||
| now := time.Now().UTC() | ||
| a.AuthCheckedAt = &now | ||
|
|
@@ -268,7 +287,15 @@ func (s *Server) handleInstallWebhook(w http.ResponseWriter, r *http.Request) { | |
| writeStorageErr(w, err, "agent not found") | ||
| return | ||
| } | ||
| if a.PAT == "" { | ||
| pat, err := a.GetPAT() | ||
| if err != nil { | ||
| slog.ErrorContext(r.Context(), "pat_decrypt_failed", | ||
| slog.String("agent_id", id), | ||
| slog.Any("error", err)) | ||
| writeError(w, http.StatusInternalServerError, errors.New("failed to retrieve agent credentials")) | ||
| return | ||
| } | ||
| if pat == "" { | ||
| writeError(w, http.StatusBadRequest, errors.New("agent has no PAT — re-create with one")) | ||
| return | ||
| } | ||
|
|
@@ -288,7 +315,7 @@ func (s *Server) handleInstallWebhook(w http.ResponseWriter, r *http.Request) { | |
|
|
||
| ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) | ||
| defer cancel() | ||
| hookID, err := github.NewClient(a.PAT).InstallWebhook(ctx, repo, callback, secret) | ||
| hookID, err := github.NewClient(pat).InstallWebhook(ctx, repo, callback, secret) | ||
| if err != nil { | ||
| writeError(w, http.StatusBadGateway, err) | ||
| return | ||
|
|
@@ -301,7 +328,7 @@ func (s *Server) handleInstallWebhook(w http.ResponseWriter, r *http.Request) { | |
| a.UpdatedAt = now | ||
| if err := s.agents.Update(r.Context(), a); err != nil { | ||
| // Try to roll back the hook so we don't leak it. | ||
| _ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, hookID) | ||
| _ = github.NewClient(pat).UninstallWebhook(ctx, repo, hookID) | ||
| writeError(w, http.StatusInternalServerError, err) | ||
| return | ||
| } | ||
|
|
@@ -319,14 +346,22 @@ func (s *Server) handleUninstallWebhook(w http.ResponseWriter, r *http.Request) | |
| writeError(w, http.StatusBadRequest, errors.New("no webhook installed")) | ||
| return | ||
| } | ||
| pat, err := a.GetPAT() | ||
| if err != nil { | ||
| slog.ErrorContext(r.Context(), "pat_decrypt_failed", | ||
| slog.String("agent_id", id), | ||
| slog.Any("error", err)) | ||
| writeError(w, http.StatusInternalServerError, errors.New("failed to retrieve agent credentials")) | ||
| return | ||
| } | ||
| repo, err := github.ParseRepo(a.RepoURL) | ||
| if err != nil { | ||
| writeError(w, http.StatusBadRequest, err) | ||
| return | ||
| } | ||
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) | ||
| defer cancel() | ||
| if err := github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID); err != nil { | ||
| if err := github.NewClient(pat).UninstallWebhook(ctx, repo, a.WebhookID); err != nil { | ||
| writeError(w, http.StatusBadGateway, err) | ||
| return | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file should not be committed to the repo. .claude/settings.local.json is a local Claude Code configuration file — the 'local' in the name indicates it is machine-specific. Add it to .gitignore and drop it from this PR. Committing it exposes the allowed Bash commands your dev environment permits, which is unnecessary noise in the repo.