console: password gate section on app Settings — set, change, turn off
Adds Task 6 (spec §5.5): a member-visible "Password gate" section on
the app Settings page, one row per instance record showing Open /
Password set, with set/change and turn-off forms. New POST
/a/{sqid}/apps/{app}/gate handler (handleAppGate) mirrors the API
PATCH's read-mutate-validate-write-audit sequence for the single Gate
field, CSRF-checked first. SettingsView gains Gates/GatesUnavailable
(settingsGates lists provision records, never exposing the hash to
the template). The password is never logged, audited, or echoed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+255
−0
internal/console/appsettings.go+123 −0internal/console/appsettings_gate_test.go+91 −0internal/console/server.go+3 −0internal/console/templates/app_settings.html+38 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index ba6703eab..c6af24e27 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -34,12 +34,15 @@ import ( |
| "log" |
| "net/http" |
| "net/url" |
| + "os" |
| "sort" |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/gate" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -110,6 +113,20 @@ type SettingsView struct { |
| // readyRouteHost skips Platform rows, so it still takes the |
| // first-route branch and still needs somewhere to type a channel. |
| NeedsChannel bool |
| + |
| + // Gates is the password-gate section (spec §5.5): one row per instance |
| + // record, with whether a password is set. GatesUnavailable carries the |
| + // reason when the records could not be read (no bucket, a store error) |
| + // and the section says so instead of rendering an empty list as "no |
| + // instances". |
| + Gates []GateRow |
| + GatesUnavailable string |
| +} |
| + |
| +// GateRow is one instance on the Settings page's password-gate section. |
| +type GateRow struct { |
| + Host string |
| + Gated bool |
| } |
| // StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue |
| @@ -238,6 +255,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) * |
| } else { |
| sv.NeedsChannel = route == "" |
| } |
| + sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName) |
| if s.cfg.Releases == nil { |
| sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run." |
| return sv |
| @@ -267,6 +285,25 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) * |
| return sv |
| } |
| +// settingsGates lists the app's instance records for the password-gate |
| +// section. The hash never reaches the template: GateRow carries a bool. |
| +func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) { |
| + if s.cfg.Releases == nil { |
| + return nil, "No deployment bucket on this host, so instance records cannot be read." |
| + } |
| + recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax) |
| + if err != nil { |
| + log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err) |
| + return nil, "Instance records could not be read just now." |
| + } |
| + rows := make([]GateRow, 0, len(recs)) |
| + for _, rec := range recs { |
| + rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""}) |
| + } |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| + return rows, "" |
| +} |
| + |
| // FeaturesCard is the Settings page's read of the app's feature flags. |
| type FeaturesCard struct { |
| Flags []FeatureFlag // key-sorted |
| @@ -634,6 +671,92 @@ func renameAdvice(err error, to string) string { |
| } |
| } |
| +// handleAppGate is the Settings page's password-gate form: set a password |
| +// on one instance host, or turn it off. Member-gated, matching the API |
| +// PATCH's door — the CLI already lets a member do this, and a form that |
| +// refuses what the CLI allows is a rule in the wrong place. CSRF first, |
| +// then the same read-mutate-validate-write-audit sequence as the PATCH, |
| +// against one field. The password is never logged, audited or echoed. |
| +func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", |
| + http.StatusSeeOther) |
| + } |
| + if s.cfg.Releases == nil { |
| + back("err", "No deployment bucket on this host, so the gate cannot be changed here.") |
| + return |
| + } |
| + host := strings.TrimSpace(r.FormValue("host")) |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + var hash string |
| + switch r.FormValue("action") { |
| + case "clear": |
| + case "set": |
| + p := r.FormValue("password") |
| + if err := gate.CheckPassword(p); err != nil { |
| + back("err", err.Error()+".") |
| + return |
| + } |
| + h, err := gate.Hash(p) |
| + if err != nil { |
| + log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", "Could not set the password — try again.") |
| + return |
| + } |
| + hash = h |
| + default: |
| + back("err", "Choose set or clear.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: gate read %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", "Could not read the instance — try again.") |
| + return |
| + } |
| + rec.Gate = hash |
| + if err := provision.ValidateShape(*rec); err != nil { |
| + back("err", err.Error()+".") |
| + return |
| + } |
| + if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + back("err", "The instance changed while this request was in flight — try again.") |
| + return |
| + } |
| + log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", "Could not update the instance — try again.") |
| + return |
| + } |
| + if hash == "" { |
| + s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)") |
| + s.bumpSerial(ctx) |
| + back("ok", "Password turned off on "+host+".") |
| + return |
| + } |
| + s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)") |
| + s.bumpSerial(ctx) |
| + back("ok", "Password set on "+host+".") |
| +} |
| + |
| // handleAppDelete puts the app in the trash: the deletion-policy marker in |
| // the bucket, then this console's row — the same order and the same clock |
| // discipline as the API door (handleAPITrashApp), behind the account-owner |
diff --git a/internal/console/appsettings_gate_test.go b/internal/console/appsettings_gate_test.go| new file mode 100644 |
| index 000000000..8b2bd2e89 |
| --- /dev/null |
| +++ b/internal/console/appsettings_gate_test.go |
| @@ -0,0 +1,91 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestAppSettingsGateSetAndClear(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-settings-gate") |
| + enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, |
| + `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-settings-gate-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // The page shows the host as open, with a form. |
| + _, _, body := get(t, c, fx.u("/apps/titogo/settings")) |
| + if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") { |
| + t.Fatalf("settings page lacks the gate section: %s", body) |
| + } |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") { |
| + t.Fatalf("set = %d -> %q", status, loc) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(rec.Gate, "$2a$10$") { |
| + t.Fatalf("stored gate = %q", rec.Gate) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| + if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") { |
| + t.Fatalf("after set: %s", body) |
| + } |
| + if strings.Contains(body, "$2a$") || strings.Contains(body, "correct horse") { |
| + t.Fatal("page leaks the hash or the password") |
| + } |
| + |
| + // A short password is refused with the rule, and nothing changes. |
| + postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}}) |
| + _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| + if !strings.Contains(body, "at least 8 characters") { |
| + t.Fatalf("short password not refused on the page: %s", body) |
| + } |
| + |
| + status, _, _ = postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("clear = %d", status) |
| + } |
| + rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") |
| + if rec.Gate != "" { |
| + t.Fatalf("gate after clear = %q", rec.Gate) |
| + } |
| + events, _ := fx.st.Events(OpsAccountID, "titogo", 20) |
| + var saw int |
| + for _, e := range events { |
| + if e.Action == "gate" { |
| + saw++ |
| + if strings.Contains(e.Detail, "correct horse") { |
| + t.Fatal("audit carries the password") |
| + } |
| + } |
| + } |
| + if saw != 2 { |
| + t.Fatalf("gate audit rows = %d, want 2", saw) |
| + } |
| +} |
| + |
| +func TestAppSettingsGateNeedsCSRF(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-settings-gate-csrf") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-settings-gate-csrf-browser") |
| + status, _, body := postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {"not-the-token"}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) |
| + if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") { |
| + t.Fatalf("got %d %s", status, body) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index c5ce9c38f..1a311c2a1 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1068,6 +1068,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // The app Settings writes (appsettings.go): owner-only, and delete/move |
| // behind passkey step-up besides. |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/place", s.handleAppPlace) |
| + // The password-gate form (appsettings.go's handleAppGate): member-gated, |
| + // unlike the owner-only writes above it. |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount) |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 5dafd1603..22e788bf8 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -84,6 +84,44 @@ certificate itself and renews it, so nothing has to visit the domain |
| first.</p> |
| {{end}} |
| +<h2>Password gate</h2> |
| +<p class="small">One shared password in front of a hostname — for a static |
| +site or a prototype that should not be world-readable. Visitors see a plain |
| +page asking for it and stay signed in for a week. It is set per instance.</p> |
| +{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p> |
| +{{else if .Settings.Gates}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Host</th><th>State</th><th></th></tr> |
| + {{range $i, $g := .Settings.Gates}} |
| + <tr> |
| + <td><code>{{$g.Host}}</code></td> |
| + <td>{{if $g.Gated}}Password set{{else}}Open{{end}}</td> |
| + <td> |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="host" value="{{$g.Host}}"> |
| + <input type="hidden" name="action" value="set"> |
| + <label class="visually-hidden" for="gate-pw-{{$i}}">Password for {{$g.Host}}</label> |
| + <input type="password" id="gate-pw-{{$i}}" name="password" minlength="8" maxlength="72" |
| + autocomplete="new-password" placeholder="password" required> |
| + <button type="submit" class="btn-sm">{{if $g.Gated}}Change{{else}}Set password{{end}}</button> |
| + </form> |
| + {{if $g.Gated}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="host" value="{{$g.Host}}"> |
| + <input type="hidden" name="action" value="clear"> |
| + <button type="submit" class="btn-sm btn-danger">Turn off</button> |
| + </form> |
| + {{end}} |
| + </td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}} |
| + |
| {{if and .Settings .Settings.MismatchTarget}} |
| <h2>Account</h2> |
| <p>This app's hosts read releases from account |