Gitfed
bastien-mrq/gitfed/ Commits/ 2579e3e

Surface pending trust approval instead of letting it fail silently

A federated collaborator grant queues a pending TrustedCA entry whenever the domain isn't already known, but nothing pointed the admin at Admin → Trust store to actually approve it — the collaborator's first clone attempt just failed with a bare SSH "Permission denied (publickey)" and no clue why. Found via a real report. Two changes: - The grant's own flash message now says so explicitly when it leaves the domain pending, instead of a generic "granted" success message (new .msg.warn style, distinct from ok/err — this isn't a failure, but needs action). - A persistent nav badge (mirrors the existing notification bell) shows on every page for admins as long as any domain is pending, so it isn't just a one-time message that can scroll past and be missed. Verified end-to-end locally: granted a collaborator at a real resolvable federated domain, watched the warn flash and badge appear, approved it from Admin → Trust store, watched the badge clear.

bastien-mrq 2026-07-29 11:52 commit 2579e3e38d5e18c32a532a0024aa14a64dd5d4ae parent 41fb26c4eaf5c3efcb18383009d39b9606813305
9 files changed +196 −115
M cmd/gitfed-web/handlers_repo.go +19 −1
M cmd/gitfed-web/render.go +30 −4
M internal/admin/admin.go +5 −0
M internal/adminrpc/client.go +6 −0
M internal/adminrpc/protocol.go +1 −0
M internal/adminrpc/server.go +4 −0
M internal/i18n/strings_en.go +57 −55
M internal/i18n/strings_fr.go +57 −55
M internal/store/trust.go +17 −0
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index f5ad201..e586a03 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -507,7 +507,25 @@ func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) { redirectWithMsg(w, r, back, err.Error(), true) return } - redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_granted", principal, roleLabel(lang, string(role))), false) + + grantedMsg := i18n.T(lang, "repo.msg_granted", principal, roleLabel(lang, string(role))) + if _, domain, ok := strings.Cut(principal, "@"); ok && domain != s.domain { + // A federated grant only becomes usable once this domain's CA is + // trusted — GrantCollaborator queues that automatically, but + // (with the default whitelist policy) it needs an admin to + // actually approve it, a separate step nothing else prompts for. + // Missing it looks like a working grant followed by a confusing + // SSH "Permission denied (publickey)" for the collaborator. + if trusted, err := s.ops.ListTrustedCAs(); err == nil { + for _, t := range trusted { + if t.Domain == domain && t.Status == store.TrustPending { + redirectWithWarnMsg(w, r, back, i18n.T(lang, "repo.msg_granted_pending_trust", principal, domain)) + return + } + } + } + } + redirectWithMsg(w, r, back, grantedMsg, false) } func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) {
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 5bd923c..07a2d8f 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -593,6 +593,7 @@ const shellHeadSrc = `<!doctype html> .msg { padding: 0.6rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; } .msg.ok { background: var(--ok-bg); color: var(--ok-fg); } .msg.err { background: var(--danger-bg); color: var(--danger-fg); } + .msg.warn { background: var(--pending-bg); color: var(--pending-fg); } .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); } .badge.pending { background: var(--pending-bg); color: var(--pending-fg); } .badge.trusted { background: var(--ok-bg); color: var(--ok-fg); } @@ -667,6 +668,11 @@ const shellHeadSrc = `<!doctype html> <a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a> </div> {{if .LoggedIn}} + {{if .PendingTrust}} + <a href="/admin/trust" class="gf-bell" title="{{t .Lang "admin.pending_trust_tooltip"}}" aria-label="{{t .Lang "admin.pending_trust_tooltip"}}"> + {{icon "shield"}}<span class="gf-bell-badge">{{.PendingTrust}}</span> + </a> + {{end}} <a href="/notifications" class="gf-bell" title="{{t .Lang "nav.notifications"}}" aria-label="{{t .Lang "nav.notifications"}}"> {{icon "bell"}}{{if .PendingNotifs}}<span class="gf-bell-badge">{{.PendingNotifs}}</span>{{end}} </a> @@ -795,20 +801,23 @@ var shellTpl = newTpl("shell", shellSrc) func (s *server) render(w http.ResponseWriter, r *http.Request, title, active string, body template.HTML) { sess, loggedIn := s.currentSession(r) - var pendingNotifs int + var pendingNotifs, pendingTrust int if loggedIn { pendingNotifs, _ = s.ops.CountPendingNotifications(sess.Principal) + if sess.IsAdmin { + pendingTrust, _ = s.ops.CountPendingTrust() + } } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = shellTpl.Execute(w, struct { Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string LoggedIn, IsAdmin bool - PendingNotifs int + PendingNotifs, PendingTrust int Body, BrandMark, IconSprite template.HTML }{ title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"), string(s.lang(r)), r.URL.RequestURI(), - loggedIn, sess.IsAdmin, pendingNotifs, body, template.HTML(brandMark), template.HTML(iconSprite), + loggedIn, sess.IsAdmin, pendingNotifs, pendingTrust, body, template.HTML(brandMark), template.HTML(iconSprite), }) } @@ -841,8 +850,15 @@ func flash(r *http.Request) template.HTML { return "" } class := "ok" - if r.URL.Query().Get("err") == "1" { + switch { + case r.URL.Query().Get("err") == "1": class = "err" + case r.URL.Query().Get("warn") == "1": + // A partial success — the action itself worked, but something + // still needs attention (e.g. a federated grant left the + // collaborator's domain pending trust approval). Not a failure, + // so not .err — but not a plain .ok either. + class = "warn" } return template.HTML(`<div class="msg ` + class + `">` + template.HTMLEscapeString(msg) + `</div>`) } @@ -866,3 +882,13 @@ func redirectWithMsg(w http.ResponseWriter, r *http.Request, path, msg string, i } http.Redirect(w, r, path+q, http.StatusSeeOther) } + +// redirectWithWarnMsg is redirectWithMsg's third state — see flash()'s +// "warn" case. +func redirectWithWarnMsg(w http.ResponseWriter, r *http.Request, path, msg string) { + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + http.Redirect(w, r, path+sep+"msg="+template.URLQueryEscaper(msg)+"&warn=1", http.StatusSeeOther) +}
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 1d47eee..424371d 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -78,6 +78,7 @@ type Ops interface { RevokeCollaborator(repoName, principal string) error ListTrustedCAs() ([]store.TrustedCA, error) + CountPendingTrust() (int, error) ApproveDomain(domain string) error PinRepo(principal, domain, repo, label string) error @@ -258,6 +259,10 @@ func (a *Admin) ListTrustedCAs() ([]store.TrustedCA, error) { return a.Store.ListTrustedCAs() } +func (a *Admin) CountPendingTrust() (int, error) { + return a.Store.CountPendingTrust() +} + func (a *Admin) ListAudit(limit int) ([]store.AuditEvent, error) { return a.Store.ListAudit(limit) }
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 8125be8..2943461 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -153,6 +153,12 @@ func (c *Client) ListTrustedCAs() ([]store.TrustedCA, error) { return out.Trust, err } +func (c *Client) CountPendingTrust() (int, error) { + var out countResult + err := c.call(methodCountPendingTrust, nil, &out) + return out.Count, err +} + func (c *Client) ApproveDomain(domain string) error { err := c.call(methodApproveDomain, domainArgs{Domain: domain}, nil) return err
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 63eeda6..519640c 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -28,6 +28,7 @@ const ( methodGrantCollaborator = "GrantCollaborator" methodRevokeCollaborator = "RevokeCollaborator" methodListTrustedCAs = "ListTrustedCAs" + methodCountPendingTrust = "CountPendingTrust" methodApproveDomain = "ApproveDomain" methodListAudit = "ListAudit" methodSetRepoPublic = "SetRepoPublic"
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 6397b5e..1e0c966 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -155,6 +155,10 @@ func (s *Server) dispatch(req wireRequest) (any, error) { trust, err := s.ops.ListTrustedCAs() return listTrustResult{Trust: trust}, err + case methodCountPendingTrust: + count, err := s.ops.CountPendingTrust() + return countResult{Count: count}, err + case methodApproveDomain: var a domainArgs if err := json.Unmarshal(req.Args, &a); err != nil {
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index 5d14432..fa0321a 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -215,20 +215,21 @@ var en = map[string]string{ "changelog.title": "Changelog", // ---------- admin ---------- - "admin.title": "Admin", - "admin.card_users": "Users", - "admin.card_users_sub": "%d admin(s), %d regular", - "admin.card_users_link": "Manage users", - "admin.card_trust": "Trust store", - "admin.card_trust_pending": "pending", - "admin.card_trust_sub": "federated domains known", - "admin.card_trust_link": "Review trust store", - "admin.card_audit": "Audit log", - "admin.card_audit_sub": "most recent events on record", - "admin.card_audit_link": "View audit log", - "admin.recent_activity": "Recent activity", - "admin.audit_allow": "allow", - "admin.audit_deny": "deny", + "admin.title": "Admin", + "admin.card_users": "Users", + "admin.card_users_sub": "%d admin(s), %d regular", + "admin.card_users_link": "Manage users", + "admin.card_trust": "Trust store", + "admin.card_trust_pending": "pending", + "admin.card_trust_sub": "federated domains known", + "admin.card_trust_link": "Review trust store", + "admin.pending_trust_tooltip": "A federated domain is waiting for trust approval — collaborators from it can't authenticate until you review it", + "admin.card_audit": "Audit log", + "admin.card_audit_sub": "most recent events on record", + "admin.card_audit_link": "View audit log", + "admin.recent_activity": "Recent activity", + "admin.audit_allow": "allow", + "admin.audit_deny": "deny", "admin.users_title": "Admin — Users", "admin.users_col_username": "Username", @@ -266,47 +267,48 @@ var en = map[string]string{ "admin.audit_note": "Showing the most recent 200 events.", // ---------- repo ---------- - "repo.owner": "owner", - "repo.copy_clone_url": "Copy clone URL", - "repo.read_only": "read-only", - "repo.https_readonly_hint": "Anonymous clone/fetch only — pushing over HTTPS is never possible, even for a public repo. Push always goes over SSH.", - "repo.empty": "This repository is empty.", - "repo.directory": "directory", - "repo.file": "file", - "repo.nothing_here": "Nothing here.", - "repo.tags": "Tags", - "repo.commits_title": "Commits", - "repo.commits_empty": "No commits yet.", - "repo.commit_label": "commit", - "repo.parent_label": "parent", - "repo.files_changed": "files changed", - "repo.diff_truncated": "This commit's diff is too large to show in full — the list above still shows every changed file.", - "repo.license": "License", - "repo.view_file": "View file", - "repo.binary_file": "Binary file — not shown.", - "repo.settings_title": "settings", - "repo.visibility_topics": "Visibility & topics", - "repo.public_desc": "Public (readable by any authenticated principal)", - "repo.topics_label": "Topics (comma-separated)", - "repo.save": "Save", - "repo.col_principal": "Principal", - "repo.col_role": "Role", - "repo.revoke": "Revoke", - "repo.grant_collaborator": "Grant collaborator", - "repo.grant": "Grant", - "repo.grant_note": "Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin → Trust store (whitelist policy).", - "repo.confirm_delete": "Delete", - "repo.confirm_revoke": "Revoke access for", - "repo.danger_zone": "Danger zone", - "repo.danger_zone_note": "Deleting removes the repo's record from Gitfed — it does not delete the bare repo on disk.", - "repo.collaborators_title": "Collaborators", - "repo.federated_collaborator": "federated collaborator", - "repo.local_collaborator": "local collaborator", - "repo.delete_record": "Delete repo record", - "repo.msg_saved": "saved settings", - "repo.msg_granted": "granted %s %s", - "repo.msg_revoked": "revoked %s", - "repo.msg_deleted": "deleted %s", + "repo.owner": "owner", + "repo.copy_clone_url": "Copy clone URL", + "repo.read_only": "read-only", + "repo.https_readonly_hint": "Anonymous clone/fetch only — pushing over HTTPS is never possible, even for a public repo. Push always goes over SSH.", + "repo.empty": "This repository is empty.", + "repo.directory": "directory", + "repo.file": "file", + "repo.nothing_here": "Nothing here.", + "repo.tags": "Tags", + "repo.commits_title": "Commits", + "repo.commits_empty": "No commits yet.", + "repo.commit_label": "commit", + "repo.parent_label": "parent", + "repo.files_changed": "files changed", + "repo.diff_truncated": "This commit's diff is too large to show in full — the list above still shows every changed file.", + "repo.license": "License", + "repo.view_file": "View file", + "repo.binary_file": "Binary file — not shown.", + "repo.settings_title": "settings", + "repo.visibility_topics": "Visibility & topics", + "repo.public_desc": "Public (readable by any authenticated principal)", + "repo.topics_label": "Topics (comma-separated)", + "repo.save": "Save", + "repo.col_principal": "Principal", + "repo.col_role": "Role", + "repo.revoke": "Revoke", + "repo.grant_collaborator": "Grant collaborator", + "repo.grant": "Grant", + "repo.grant_note": "Granting a collaborator on a domain not yet known to this instance triggers federation discovery of that domain's CA — an instance admin needs to approve it under Admin → Trust store (whitelist policy).", + "repo.confirm_delete": "Delete", + "repo.confirm_revoke": "Revoke access for", + "repo.danger_zone": "Danger zone", + "repo.danger_zone_note": "Deleting removes the repo's record from Gitfed — it does not delete the bare repo on disk.", + "repo.collaborators_title": "Collaborators", + "repo.federated_collaborator": "federated collaborator", + "repo.local_collaborator": "local collaborator", + "repo.delete_record": "Delete repo record", + "repo.msg_saved": "saved settings", + "repo.msg_granted": "granted %s %s", + "repo.msg_granted_pending_trust": "granted %s — but %s is pending trust approval in Admin → Trust store, so they can't authenticate until you approve it", + "repo.msg_revoked": "revoked %s", + "repo.msg_deleted": "deleted %s", // ---------- merge requests ---------- "mr.list_title": "Merge requests",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index c8ef7ea..1f12201 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -215,20 +215,21 @@ var fr = map[string]string{ "changelog.title": "Changelog", // ---------- admin ---------- - "admin.title": "Administration", - "admin.card_users": "Utilisateurs", - "admin.card_users_sub": "%d admin(s), %d classique(s)", - "admin.card_users_link": "Gérer les utilisateurs", - "admin.card_trust": "Confiance fédérée", - "admin.card_trust_pending": "en attente", - "admin.card_trust_sub": "domaines fédérés connus", - "admin.card_trust_link": "Revoir la confiance fédérée", - "admin.card_audit": "Journal d'audit", - "admin.card_audit_sub": "événements récents enregistrés", - "admin.card_audit_link": "Voir le journal d'audit", - "admin.recent_activity": "Activité récente", - "admin.audit_allow": "autorisé", - "admin.audit_deny": "refusé", + "admin.title": "Administration", + "admin.card_users": "Utilisateurs", + "admin.card_users_sub": "%d admin(s), %d classique(s)", + "admin.card_users_link": "Gérer les utilisateurs", + "admin.card_trust": "Confiance fédérée", + "admin.card_trust_pending": "en attente", + "admin.card_trust_sub": "domaines fédérés connus", + "admin.card_trust_link": "Revoir la confiance fédérée", + "admin.pending_trust_tooltip": "Un domaine fédéré attend une approbation de confiance — ses collaborateurs ne peuvent pas s'authentifier tant que ce n'est pas fait", + "admin.card_audit": "Journal d'audit", + "admin.card_audit_sub": "événements récents enregistrés", + "admin.card_audit_link": "Voir le journal d'audit", + "admin.recent_activity": "Activité récente", + "admin.audit_allow": "autorisé", + "admin.audit_deny": "refusé", "admin.users_title": "Administration — Utilisateurs", "admin.users_col_username": "Utilisateur", @@ -266,47 +267,48 @@ var fr = map[string]string{ "admin.audit_note": "Affichage des 200 événements les plus récents.", // ---------- repo ---------- - "repo.owner": "propriétaire", - "repo.copy_clone_url": "Copier l'URL de clonage", - "repo.read_only": "lecture seule", - "repo.https_readonly_hint": "Clone/fetch anonyme uniquement — impossible de pousser en HTTPS, même sur un dépôt public. Le push passe toujours par SSH.", - "repo.empty": "Ce dépôt est vide.", - "repo.directory": "dossier", - "repo.file": "fichier", - "repo.nothing_here": "Rien ici.", - "repo.tags": "Tags", - "repo.commits_title": "Commits", - "repo.commits_empty": "Aucun commit pour le moment.", - "repo.commit_label": "commit", - "repo.parent_label": "parent", - "repo.files_changed": "fichiers modifiés", - "repo.diff_truncated": "Le diff de ce commit est trop volumineux pour être affiché en entier — la liste ci-dessus montre tout de même chaque fichier modifié.", - "repo.license": "Licence", - "repo.view_file": "Voir le fichier", - "repo.binary_file": "Fichier binaire — non affiché.", - "repo.settings_title": "paramètres", - "repo.visibility_topics": "Visibilité et sujets", - "repo.public_desc": "Public (lisible par tout principal authentifié)", - "repo.topics_label": "Sujets (séparés par des virgules)", - "repo.save": "Enregistrer", - "repo.col_principal": "Principal", - "repo.col_role": "Rôle", - "repo.revoke": "Révoquer", - "repo.grant_collaborator": "Ajouter un collaborateur", - "repo.grant": "Accorder", - "repo.grant_note": "Accorder un accès à un domaine encore inconnu de cette instance déclenche la découverte fédérée de la CA de ce domaine — un administrateur de l'instance doit l'approuver dans Administration → Confiance fédérée (politique de liste blanche).", - "repo.confirm_delete": "Supprimer", - "repo.confirm_revoke": "Révoquer l'accès de", - "repo.danger_zone": "Zone de danger", - "repo.danger_zone_note": "La suppression retire l'entrée du dépôt de Gitfed — elle ne supprime pas le dépôt bare sur le disque.", - "repo.collaborators_title": "Collaborateurs", - "repo.federated_collaborator": "collaborateur fédéré", - "repo.local_collaborator": "collaborateur local", - "repo.delete_record": "Supprimer l'entrée du dépôt", - "repo.msg_saved": "paramètres enregistrés", - "repo.msg_granted": "%s ajouté avec le rôle %s", - "repo.msg_revoked": "%s révoqué", - "repo.msg_deleted": "%s supprimé", + "repo.owner": "propriétaire", + "repo.copy_clone_url": "Copier l'URL de clonage", + "repo.read_only": "lecture seule", + "repo.https_readonly_hint": "Clone/fetch anonyme uniquement — impossible de pousser en HTTPS, même sur un dépôt public. Le push passe toujours par SSH.", + "repo.empty": "Ce dépôt est vide.", + "repo.directory": "dossier", + "repo.file": "fichier", + "repo.nothing_here": "Rien ici.", + "repo.tags": "Tags", + "repo.commits_title": "Commits", + "repo.commits_empty": "Aucun commit pour le moment.", + "repo.commit_label": "commit", + "repo.parent_label": "parent", + "repo.files_changed": "fichiers modifiés", + "repo.diff_truncated": "Le diff de ce commit est trop volumineux pour être affiché en entier — la liste ci-dessus montre tout de même chaque fichier modifié.", + "repo.license": "Licence", + "repo.view_file": "Voir le fichier", + "repo.binary_file": "Fichier binaire — non affiché.", + "repo.settings_title": "paramètres", + "repo.visibility_topics": "Visibilité et sujets", + "repo.public_desc": "Public (lisible par tout principal authentifié)", + "repo.topics_label": "Sujets (séparés par des virgules)", + "repo.save": "Enregistrer", + "repo.col_principal": "Principal", + "repo.col_role": "Rôle", + "repo.revoke": "Révoquer", + "repo.grant_collaborator": "Ajouter un collaborateur", + "repo.grant": "Accorder", + "repo.grant_note": "Accorder un accès à un domaine encore inconnu de cette instance déclenche la découverte fédérée de la CA de ce domaine — un administrateur de l'instance doit l'approuver dans Administration → Confiance fédérée (politique de liste blanche).", + "repo.confirm_delete": "Supprimer", + "repo.confirm_revoke": "Révoquer l'accès de", + "repo.danger_zone": "Zone de danger", + "repo.danger_zone_note": "La suppression retire l'entrée du dépôt de Gitfed — elle ne supprime pas le dépôt bare sur le disque.", + "repo.collaborators_title": "Collaborateurs", + "repo.federated_collaborator": "collaborateur fédéré", + "repo.local_collaborator": "collaborateur local", + "repo.delete_record": "Supprimer l'entrée du dépôt", + "repo.msg_saved": "paramètres enregistrés", + "repo.msg_granted": "%s ajouté avec le rôle %s", + "repo.msg_granted_pending_trust": "%s ajouté — mais %s est en attente d'approbation dans Admin → Trust store, il/elle ne pourra pas s'authentifier tant que ce n'est pas fait", + "repo.msg_revoked": "%s révoqué", + "repo.msg_deleted": "%s supprimé", // ---------- merge requests ---------- "mr.list_title": "Merge requests",
internal/store/trust.go
diff --git a/internal/store/trust.go b/internal/store/trust.go index 1c6ff1b..238e527 100644 --- a/internal/store/trust.go +++ b/internal/store/trust.go @@ -48,6 +48,23 @@ func (s *Store) ListTrustedCAs() ([]TrustedCA, error) { return out, err } +// CountPendingTrust is a cheap version of ListTrustedCAs for the admin nav +// badge (see cmd/gitfed-web/render.go) — computed on every page render for +// a logged-in admin, so it stays a count rather than the full list. +func (s *Store) CountPendingTrust() (int, error) { + all, err := s.ListTrustedCAs() + if err != nil { + return 0, err + } + n := 0 + for _, t := range all { + if t.Status == TrustPending { + n++ + } + } + return n, nil +} + // ApproveTrustedCA flips a pending CA record to trusted. func (s *Store) ApproveTrustedCA(domain string) error { return s.db.Update(func(tx *bolt.Tx) error {