diff --git a/CHANGELOG.json b/CHANGELOG.json index 65cc893c..6549fc30 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -8,6 +8,47 @@ "grokify" ], "releases": [ + { + "version": "v0.32.1", + "date": "2026-11-06", + "added": [ + { + "description": "`systemauth.Server.Principal` returns a human principal's profile from social login's directory", + "commit": "dd0381d" + }, + { + "description": "`console.Options.Profile` supplies the signed-in person's name, email and avatar to the console's session", + "commit": "4571bca" + }, + { + "description": "The connections API returns each connection's `health.details`, what its last successful check found the credential reaches, such as the Search Console properties an account can read", + "commit": "846ed7b" + } + ], + "fixed": [ + { + "description": "The organization console's profile menu showed no name or picture; its session now carries them, filled by `systemauthsvc` from SystemAuth's directory", + "commit": "4571bca" + } + ], + "tests": [ + { + "description": "The console's session with and without a profile, and `Server.Principal` with and without social login", + "commit": "e54e806" + } + ], + "dependencies": [ + { + "description": "Bump `github.com/plexusone/connectforge` from 0.6.0 to 0.7.0", + "commit": "846ed7b" + } + ], + "documentation": [ + { + "description": "The console guide describes the person's profile in the session" + } + ] + }, { "version": "v0.32.0", "date": "2026-11-05", diff --git a/CHANGELOG.md b/CHANGELOG.md index e59d5ca7..e6bbcf99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.32.1] - 2026-11-06 + +### Added + +- `systemauth.Server.Principal` returns a human principal's profile from social login's directory ([`dd0381d`](https://github.com/plexusone/systemforge/commit/dd0381d)) +- `console.Options.Profile` supplies the signed-in person's name, email and avatar to the console's session ([`4571bca`](https://github.com/plexusone/systemforge/commit/4571bca)) +- The connections API returns each connection's `health.details`, what its last successful check found the credential reaches, such as the Search Console properties an account can read ([`846ed7b`](https://github.com/plexusone/systemforge/commit/846ed7b)) + +### Fixed + +- The organization console's profile menu showed no name or picture; its session now carries them, filled by `systemauthsvc` from SystemAuth's directory ([`4571bca`](https://github.com/plexusone/systemforge/commit/4571bca)) + +### Dependencies + +- _1 dependency update_ + +### Documentation + +- The console guide describes the person's profile in the session + +### Tests + +- The console's session with and without a profile, and `Server.Principal` with and without social login ([`e54e806`](https://github.com/plexusone/systemforge/commit/e54e806)) + ## [v0.32.0] - 2026-11-05 ### Highlights @@ -1855,7 +1879,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro - GitHub Actions lint workflow with golangci-lint ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) - Dependabot configuration for Go modules and GitHub Actions ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) -[unreleased]: https://github.com/plexusone/systemforge/compare/v0.32.0...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.32.1...HEAD +[v0.32.1]: https://github.com/plexusone/systemforge/compare/v0.32.0...v0.32.1 [v0.32.0]: https://github.com/plexusone/systemforge/compare/v0.31.0...v0.32.0 [v0.31.0]: https://github.com/plexusone/systemforge/compare/v0.30.5...v0.31.0 [v0.30.5]: https://github.com/plexusone/systemforge/compare/v0.30.4...v0.30.5 diff --git a/console/console.go b/console/console.go index 44b0726f..21ac316a 100644 --- a/console/console.go +++ b/console/console.go @@ -67,6 +67,14 @@ type Organization struct { Apps map[string]string `json:"apps,omitempty"` } +// Profile is how the signed-in person appears in the console's chrome: the +// name and picture in the profile menu. +type Profile struct { + Name string + Email string + AvatarURL string +} + // App is an application in the deployment's catalog. type App struct { ID string `json:"id"` @@ -92,6 +100,10 @@ type Options struct { // Apps lists the deployment's application catalog, for the console's // product switcher. Nil lists none. Apps func(ctx context.Context) ([]App, error) + // Profile returns the person's name, email and picture, for the + // profile menu, as the applications show them. Nil, or an error, shows + // the person without them; the console still works. + Profile func(ctx context.Context, principal uuid.UUID) (Profile, error) // Connections, if set, serves the connections section. Connections *connections.Connections // WebDir holds the built console pages. Empty serves only the API. @@ -198,6 +210,9 @@ type wireSession struct { type wireUser struct { ID string `json:"id"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` Memberships []wireMembership `json:"memberships"` } @@ -248,9 +263,19 @@ func (c *console) session(w http.ResponseWriter, r *http.Request) { } catalog = append(catalog, apps...) } + user := &wireUser{ID: pid.String(), Memberships: ms} + if c.o.Profile != nil { + p, err := c.o.Profile(r.Context(), pid) + if err != nil { + // The profile menu then shows no name or picture. + c.o.Logger.Error("console: reading the profile", "principal", pid, "error", err) + } else { + user.Name, user.Email, user.AvatarURL = p.Name, p.Email, p.AvatarURL + } + } writeJSON(w, http.StatusOK, wireSession{ Authenticated: true, UserID: pid.String(), - User: &wireUser{ID: pid.String(), Memberships: ms}, + User: user, Apps: catalog, }) } diff --git a/console/console_test.go b/console/console_test.go index 58ac223d..8c398f10 100644 --- a/console/console_test.go +++ b/console/console_test.go @@ -43,6 +43,7 @@ type env struct { admin uuid.UUID member uuid.UUID sessionError error + profileError error signedOut bool } @@ -79,6 +80,18 @@ func newEnv(t *testing.T, webDir string, withConnections bool) *env { Apps: func(context.Context) ([]console.App, error) { return []console.App{{ID: "portfolio", Name: "Portfolio", URL: "https://portfolio.example.com", Order: 3}}, nil }, + Profile: func(_ context.Context, p uuid.UUID) (console.Profile, error) { + if e.profileError != nil { + return console.Profile{}, e.profileError + } + switch p { + case e.admin: + return console.Profile{Name: "Ada Admin", Email: "ada@example.com", AvatarURL: "https://img.example.com/ada.png"}, nil + case e.member: + return console.Profile{Name: "Max Member", Email: "max@example.com", AvatarURL: "https://img.example.com/max.png"}, nil + } + return console.Profile{}, errors.New("no such person") + }, ReturnTo: func(raw string) (string, error) { if strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") || strings.HasPrefix(raw, "https://app.example.com/") { return raw, nil @@ -169,6 +182,9 @@ func TestSession(t *testing.T) { Apps []console.App User struct { ID string + Name string + Email string + AvatarURL string `json:"avatar_url"` Memberships []struct { OrganizationID string `json:"organization_id"` OrganizationName string `json:"organization_name"` @@ -183,6 +199,10 @@ func TestSession(t *testing.T) { if !s.Authenticated || s.User.ID != e.admin.String() || len(s.User.Memberships) != 1 { t.Fatalf("session: %+v", s) } + // The profile menu shows the person as the applications do. + if s.User.Name != "Ada Admin" || s.User.Email != "ada@example.com" || s.User.AvatarURL != "https://img.example.com/ada.png" { + t.Errorf("profile: %+v", s.User) + } m := s.User.Memberships[0] if m.OrganizationID != e.org.String() || m.OrganizationName != "Acme" || m.Role != "admin" || m.Applications[console.AppID] != "admin" { t.Fatalf("membership: %+v", m) @@ -193,6 +213,34 @@ func TestSession(t *testing.T) { t.Fatalf("products: %+v, catalog %+v", m.Applications, s.Apps) } + // Each person's session carries their own profile. + resp, body = e.do(t, e.member, http.MethodGet, console.SessionPath, nil, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("member's session: %d %s", resp.StatusCode, body) + } + var ms struct { + User struct { + ID string + Name string + Email string + AvatarURL string `json:"avatar_url"` + } + } + if err := json.Unmarshal([]byte(body), &ms); err != nil { + t.Fatal(err) + } + if ms.User.ID != e.member.String() || ms.User.Name != "Max Member" || ms.User.Email != "max@example.com" || ms.User.AvatarURL != "https://img.example.com/max.png" { + t.Errorf("member's profile: %+v", ms.User) + } + + // A profile that cannot be read leaves it out; the session still works. + e.profileError = errors.New("directory down") + resp, body = e.do(t, e.admin, http.MethodGet, console.SessionPath, nil, nil) + if resp.StatusCode != http.StatusOK || !strings.Contains(body, `"authenticated":true`) || strings.Contains(body, `"name":"Ada`) { + t.Fatalf("without a profile: %d %s", resp.StatusCode, body) + } + e.profileError = nil + e.sessionError = errors.New("store down") if resp, _ := e.do(t, e.admin, http.MethodGet, console.SessionPath, nil, nil); resp.StatusCode != http.StatusInternalServerError { t.Fatalf("a failing session store: %d", resp.StatusCode) diff --git a/docs/guides/console.md b/docs/guides/console.md index 48fcbdcc..9d95f92e 100644 --- a/docs/guides/console.md +++ b/docs/guides/console.md @@ -51,7 +51,7 @@ The session routes under `/console/bff` speak the format `@plexusone/auth`'s | Call | Does | |---|---| -| `GET /console/bff/session` | The person and their organizations, personal first, and the deployment's app catalog. Each organization grants the application `console` with the person's organization role, besides their roles in the products, so the console's product switcher links back to them. Without a session: `{"authenticated":false}` | +| `GET /console/bff/session` | The person, with their name, email and avatar as the applications show them, and their organizations, personal first, and the deployment's app catalog. Each organization grants the application `console` with the person's organization role, besides their roles in the products, so the console's product switcher links back to them. Without a session: `{"authenticated":false}` | | `GET /console/bff/auth/login?return_to=` | Sends the person to `/login`, and back to `return_to`, a path on this host | | `GET /console/bff/auth/{provider}?return_to=` | The same, straight to that provider's sign-in | | `POST /console/bff/auth/logout` | Signs the person out of SystemAuth, as `POST /logout` does, and answers `204` | diff --git a/docs/releases/changelog.md b/docs/releases/changelog.md index e59d5ca7..e6bbcf99 100644 --- a/docs/releases/changelog.md +++ b/docs/releases/changelog.md @@ -9,6 +9,30 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.32.1] - 2026-11-06 + +### Added + +- `systemauth.Server.Principal` returns a human principal's profile from social login's directory ([`dd0381d`](https://github.com/plexusone/systemforge/commit/dd0381d)) +- `console.Options.Profile` supplies the signed-in person's name, email and avatar to the console's session ([`4571bca`](https://github.com/plexusone/systemforge/commit/4571bca)) +- The connections API returns each connection's `health.details`, what its last successful check found the credential reaches, such as the Search Console properties an account can read ([`846ed7b`](https://github.com/plexusone/systemforge/commit/846ed7b)) + +### Fixed + +- The organization console's profile menu showed no name or picture; its session now carries them, filled by `systemauthsvc` from SystemAuth's directory ([`4571bca`](https://github.com/plexusone/systemforge/commit/4571bca)) + +### Dependencies + +- _1 dependency update_ + +### Documentation + +- The console guide describes the person's profile in the session + +### Tests + +- The console's session with and without a profile, and `Server.Principal` with and without social login ([`e54e806`](https://github.com/plexusone/systemforge/commit/e54e806)) + ## [v0.32.0] - 2026-11-05 ### Highlights @@ -1855,7 +1879,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro - GitHub Actions lint workflow with golangci-lint ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) - Dependabot configuration for Go modules and GitHub Actions ([`e2f98d5`](https://github.com/plexusone/systemforge/commit/e2f98d5)) -[unreleased]: https://github.com/plexusone/systemforge/compare/v0.32.0...HEAD +[unreleased]: https://github.com/plexusone/systemforge/compare/v0.32.1...HEAD +[v0.32.1]: https://github.com/plexusone/systemforge/compare/v0.32.0...v0.32.1 [v0.32.0]: https://github.com/plexusone/systemforge/compare/v0.31.0...v0.32.0 [v0.31.0]: https://github.com/plexusone/systemforge/compare/v0.30.5...v0.31.0 [v0.30.5]: https://github.com/plexusone/systemforge/compare/v0.30.4...v0.30.5 diff --git a/docs/releases/index.md b/docs/releases/index.md index d9085df4..36247c90 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -4,7 +4,11 @@ Release notes, one file per version, newest first. The [changelog](changelog.md) is generated from `CHANGELOG.json` and lists every change with its commit. -The latest release, [v0.32.0](v0.32.0.md), lets a manager replace a +The latest release, [v0.32.1](v0.32.1.md), shows the signed-in person's +name and picture in the organization console, as the applications do. It +adds no breaking change. + +Before it, [v0.32.0](v0.32.0.md) let a manager replace a managed principal's login tokens in one call, so a lost printed card is retired and reprinted in one step. Its sign-in methods page shows what is linked and links back to the application. It adds no breaking change. diff --git a/docs/releases/v0.32.1.md b/docs/releases/v0.32.1.md new file mode 100644 index 00000000..3f329356 --- /dev/null +++ b/docs/releases/v0.32.1.md @@ -0,0 +1,31 @@ +# v0.32.1 + +**Release Date:** 2026-11-06 + +The organization console shows the signed-in person the way the +applications do, and connections show what their last check found. + +- **Name and picture in the console.** The console's session, + `GET /console/bff/session`, now carries the person's name, email and + avatar, so its profile menu shows their picture instead of a placeholder. + `console.Options.Profile` supplies them; `systemauthsvc` fills it from + SystemAuth's directory, the same source as the `name` and `picture` + claims. Without it, or when it fails, the console works as before. +- **`systemauth.Server.Principal`** returns a human principal's profile + from social login's directory. +- **What a connection reaches.** With connectforge v0.7.0, each + connection's `health.details` in the connections API lists what its + last successful check found, such as the Search Console properties an + account can read. A failed check keeps the last ones. + +The release adds no breaking change. + +## Upgrading + +Nothing is required. A deployment running the console through +`systemauthsvc` shows the person's name and picture after upgrading. +Existing connections have no details until their next check. + +## Dependencies + +- `github.com/plexusone/connectforge` 0.7.0. diff --git a/go.mod b/go.mod index 09056ee5..88307da7 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/jackc/pgx/v5 v5.12.0 github.com/mattn/go-sqlite3 v1.14.52 github.com/ory/fosite v0.49.0 - github.com/plexusone/connectforge v0.6.0 + github.com/plexusone/connectforge v0.7.0 github.com/plexusone/keyforge v0.2.0 github.com/plexusone/omni-aws v0.11.0 github.com/plexusone/omnimail v0.1.0 @@ -235,7 +235,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect github.com/yaronf/httpsign v0.5.2 // indirect - github.com/zclconf/go-cty v1.19.0 // indirect + github.com/zclconf/go-cty v1.20.0 // indirect github.com/zclconf/go-cty-yaml v1.2.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index da2cf586..30c42cd8 100644 --- a/go.sum +++ b/go.sum @@ -531,8 +531,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240917153116-6f2963f01587 h1:xzZOeCMQLA/W198ZkdVdt4EKFKJtS26B773zNU377ZY= github.com/planetscale/vtprotobuf v0.6.1-0.20240917153116-6f2963f01587/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/plexusone/connectforge v0.6.0 h1:s/1upvVidylAd9cbafnxoAX+rVrhSIgtnjpgar1VZZ0= -github.com/plexusone/connectforge v0.6.0/go.mod h1:QJKDTQYcfAiguR9PIRcugR7aNnA6wnxGhCcT5CGUigg= +github.com/plexusone/connectforge v0.7.0 h1:ypR3yXqAVLkJ6CSKCqk6tieCxmchktVpqpKycfmO1Hw= +github.com/plexusone/connectforge v0.7.0/go.mod h1:s6gApsI+mzXxFlhjCdbv4a4Y1l9MPkYUCmQk3xVcHjg= github.com/plexusone/keyforge v0.2.0 h1:Dr/KfFuQBAi2hW/fZkqe5u/QLQ43is4oWOFvUhandFQ= github.com/plexusone/keyforge v0.2.0/go.mod h1:LXDweBp3uodk4iYWmxyqvFptjgEwFpWzzhdB86BQl4U= github.com/plexusone/omni-aws v0.11.0 h1:hQM8wadLpy55K/Q30ci1wcRZ/MHOmHOlYi4ooW7ZFyE= @@ -680,8 +680,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -github.com/zclconf/go-cty v1.19.0 h1:IV8WdqYZc2c5rLX9bEoLNXKojBAp0MZPBHMIrCoa/s4= -github.com/zclconf/go-cty v1.19.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= +github.com/zclconf/go-cty v1.20.0 h1:V2Mh9pu2xWPATCY7US3LnGBNSq/AAZDsqQZmmOAV7DI= +github.com/zclconf/go-cty v1.20.0/go.mod h1:12W89jGn3JCOIQi7infWr9m80rOkb5RNYJqXMZcN4c8= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-yaml v1.2.0 h1:GDyL4+e/Qe/S0B7YaecMLbVvAR/Mp21CXMOSiCTOi1M= diff --git a/identity/systemauth/social_login.go b/identity/systemauth/social_login.go index 4132a6ca..d9c63d6b 100644 --- a/identity/systemauth/social_login.go +++ b/identity/systemauth/social_login.go @@ -770,6 +770,16 @@ func (s *Server) CurrentLoginSession(r *http.Request) (*LoginSession, error) { return s.social.currentSession(r) } +// Principal returns a human principal's profile from the directory social +// login uses, such as its display name and avatar. It returns +// ErrPrincipalNotFound when there is none or social login is not enabled. +func (s *Server) Principal(ctx context.Context, id uuid.UUID) (*HumanPrincipal, error) { + if s.social == nil { + return nil, ErrPrincipalNotFound + } + return s.social.directory.GetPrincipal(ctx, id) +} + // ValidateReturnTo checks a place to send a person after an action, by the // policy sign-in uses for return_to: an absolute path on this host, or a URL // on an allowed redirect origin. It returns the URL to use, or diff --git a/identity/systemauth/social_login_test.go b/identity/systemauth/social_login_test.go index bb425835..aba1dd39 100644 --- a/identity/systemauth/social_login_test.go +++ b/identity/systemauth/social_login_test.go @@ -863,3 +863,29 @@ func TestEndLoginSession(t *testing.T) { t.Errorf("without social login: %v", err) } } + +func TestServerPrincipal(t *testing.T) { + ctx := context.Background() + dir := NewMemoryPrincipalDirectory() + s := newSocialTestServer(t, newFakeGitHub(t), dir) + p, err := ResolveExternalLogin(ctx, dir, ExternalLogin{Provider: "google", Subject: "g-1", Email: "g@example.com", + EmailVerified: true, EmailAuthoritative: true, Name: "Grace", AvatarURL: "https://img.example.com/g.png"}, time.Now()) + if err != nil { + t.Fatal(err) + } + got, err := s.Principal(ctx, p.ID) + if err != nil || got.DisplayName != "Grace" || got.Email != "g@example.com" || got.AvatarURL != "https://img.example.com/g.png" { + t.Fatalf("Principal = %+v, %v", got, err) + } + if _, err := s.Principal(ctx, uuid.New()); !errors.Is(err, ErrPrincipalNotFound) { + t.Errorf("an unknown principal: %v", err) + } + + plain, err := NewEmbedded(Config{Issuer: testIssuer}) + if err != nil { + t.Fatal(err) + } + if _, err := plain.Principal(ctx, p.ID); !errors.Is(err, ErrPrincipalNotFound) { + t.Errorf("without social login: %v", err) + } +} diff --git a/identity/systemauthsvc/console.go b/identity/systemauthsvc/console.go index e512dca5..212379ea 100644 --- a/identity/systemauthsvc/console.go +++ b/identity/systemauthsvc/console.go @@ -26,6 +26,7 @@ func (s *Service) mountConsole(c *Config) error { ReturnTo: s.server.ValidateReturnTo, SignOut: s.server.EndLoginSession, Apps: s.consoleApps, + Profile: s.consoleProfile, Connections: s.conns, WebDir: c.Console.WebDir, LoginPath: systemauth.LoginPath, @@ -83,3 +84,13 @@ func (s *Service) consoleApps(context.Context) ([]console.App, error) { } return out, nil } + +// consoleProfile is the signed-in person's name, email and avatar, as the +// applications' tokens carry them. +func (s *Service) consoleProfile(ctx context.Context, principal uuid.UUID) (console.Profile, error) { + p, err := s.server.Principal(ctx, principal) + if err != nil { + return console.Profile{}, err + } + return console.Profile{Name: p.DisplayName, Email: p.Email, AvatarURL: p.AvatarURL}, nil +} diff --git a/mkdocs.yml b/mkdocs.yml index 4e89054c..0a7449c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -169,6 +169,7 @@ nav: - Releases: - Overview: releases/index.md - Changelog: releases/changelog.md + - v0.32.1: releases/v0.32.1.md - v0.32.0: releases/v0.32.0.md - v0.31.0: releases/v0.31.0.md - v0.30.5: releases/v0.30.5.md