diff --git a/account_contributions.go b/account_contributions.go new file mode 100644 index 0000000..2ca48c3 --- /dev/null +++ b/account_contributions.go @@ -0,0 +1,142 @@ +package user + +import ( + "context" + "errors" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "gorm.io/gorm" +) + +var _ pact.HasAccountContributions = (*Plugin)(nil) + +// backendLinkLang prefixes the phrase keys of the account link. +const backendLinkLang = "golem15.user::lang.backend_link." + +// AccountContributions adds the "Frontend account" section to the admin's +// My account page (D-04): its status names the linked frontend account, +// "link" starts a link request and answers the site URL the administrator +// opens to sign in there as the frontend user and confirm (D-05; the link is +// written only by that confirmation), and "unlink" removes the link. +func (p *Plugin) AccountContributions() []pact.AccountContribution { + return []pact.AccountContribution{{ + Name: "frontend_account", + Label: backendLinkLang + "section", + Description: backendLinkLang + "section_comment", + Status: p.frontendAccountStatus, + Actions: []pact.AccountAction{ + { + Name: "link", + Label: backendLinkLang + "link", + Applies: func(ctx context.Context, adminID uint) (bool, error) { + link, err := p.backendLinks().LinkFor(ctx, adminID) + return link == nil, err + }, + Run: p.startFrontendLink, + }, + { + Name: "unlink", + Label: backendLinkLang + "unlink", + Confirm: backendLinkLang + "unlink_confirm", + Danger: true, + Applies: func(ctx context.Context, adminID uint) (bool, error) { + link, err := p.backendLinks().LinkFor(ctx, adminID) + return link != nil, err + }, + Run: p.unlinkOwnFrontendAccount, + }, + }, + }} +} + +// backendLinks returns the published link service, or one over the app. +func (p *Plugin) backendLinks() *classes.BackendLinks { + if p.app != nil { + if links, ok := p.app.Lookup[*classes.BackendLinks](); ok && links != nil { + return links + } + } + return &classes.BackendLinks{App: p.app} +} + +// frontendAccountStatus answers "Linked to " or "Not linked" in the +// request locale for the signed-in administrator. +func (p *Plugin) frontendAccountStatus(ctx context.Context, adminID uint) (string, error) { + link, err := p.backendLinks().LinkFor(ctx, adminID) + if err != nil { + return "", err + } + if link == nil { + return backendLinkLang + "status_unlinked", nil + } + email, err := p.frontendEmail(ctx, link.UserID) + if err != nil { + return "", err + } + const key = backendLinkLang + "status_linked" + if p.app != nil { + if tr, ok := p.app.Lookup[*phrasebook.Translator](); ok && tr != nil { + if text := tr.Get(ctx, key, map[string]string{"email": email}); text != "" && text != key { + return text, nil + } + } + } + return "Linked to " + email, nil +} + +// frontendEmail reads the email of a frontend user, deactivated or not. +func (p *Plugin) frontendEmail(ctx context.Context, userID uint) (string, error) { + var db *gorm.DB + if tx, ok := cabana.TxFromContext(ctx); ok { + db = tx.Session(&gorm.Session{NewDB: true}) + } else if p.app != nil { + db, _ = p.app.Lookup[*gorm.DB]() + } + if db == nil { + return "", errors.New("golem15.user: gorm handle is missing") + } + var emails []string + if err := db.WithContext(ctx).Unscoped().Model(&models.User{}).Where("id = ?", userID).Pluck("email", &emails).Error; err != nil { + return "", err + } + if len(emails) == 0 { + return "", nil + } + return emails[0], nil +} + +// startFrontendLink creates a link request for the signed-in administrator +// and answers the site page that confirms it: +// golem15.user.frontend_url (else app.url) + /link-admin?code=. +func (p *Plugin) startFrontendLink(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) { + plain, _, err := p.backendLinks().CreateLinkRequest(ctx, in.AdminID) + if err != nil { + return pact.AccountActionResult{}, err + } + return pact.AccountActionResult{ + Message: backendLinkLang + "link_started", + URL: classes.FrontendURL(p.app) + "/link-admin?code=" + plain, + }, nil +} + +// unlinkOwnFrontendAccount removes the signed-in administrator's link in +// the action's transaction. +func (p *Plugin) unlinkOwnFrontendAccount(ctx context.Context, in pact.AccountActionInput) (pact.AccountActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AccountActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction") + } + links := p.backendLinks() + link, err := links.LinkFor(ctx, in.AdminID) + if err != nil { + return pact.AccountActionResult{}, err + } + if err := links.RemoveLink(ctx, tx, link); err != nil { + return pact.AccountActionResult{}, err + } + return pact.AccountActionResult{Message: backendLinkLang + "unlinked"}, nil +} diff --git a/backend_link_smoke_test.go b/backend_link_smoke_test.go new file mode 100644 index 0000000..d46239d --- /dev/null +++ b/backend_link_smoke_test.go @@ -0,0 +1,272 @@ +package user + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// subjectSpy records the pact.MCPSubjectChanged events fired on an app. +type subjectSpy struct { + mu sync.Mutex + events []pact.MCPSubjectChanged +} + +func (s *subjectSpy) has(guard string, id uint, kind string) bool { + s.mu.Lock() + defer s.mu.Unlock() + return slices.Contains(s.events, pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind}) +} + +func (s *subjectSpy) reset() { + s.mu.Lock() + s.events = nil + s.mu.Unlock() +} + +// spySubjects listens for the subject events the plugin fires. +func (e *adminEnv) spySubjects() *subjectSpy { + spy := &subjectSpy{} + e.app.Events.Listen("test.subjects", func(_ context.Context, ev *pact.MCPSubjectChanged) error { + spy.mu.Lock() + spy.events = append(spy.events, *ev) + spy.mu.Unlock() + return nil + }) + return spy +} + +// adminID is the backend user id inside an admin session token. +func adminID(t *testing.T, token string) uint { + t.Helper() + sub, _, _, _, err := bouncer.VerifyClaimsAudience(token, adminHarnessSecret, bouncer.AudienceBackend) + if err != nil { + t.Fatal(err) + } + id, err := strconv.ParseUint(sub, 10, 64) + if err != nil { + t.Fatal(err) + } + return uint(id) +} + +// adminLogin reads an administrator's login. +func (e *adminEnv) adminLogin(t *testing.T, id uint) string { + t.Helper() + var login string + if err := e.gdb.Raw(`SELECT login FROM backend_users WHERE id = ?`, id).Scan(&login).Error; err != nil { + t.Fatal(err) + } + return login +} + +// freshUserToken mints a frontend session token issued now. +func freshUserToken(t *testing.T, user *models.User) string { + t.Helper() + token, _, err := bouncer.Mint(sessionSecret, strconv.FormatUint(uint64(user.ID), 10), "https://app.example.test", time.Hour) + if err != nil { + t.Fatal(err) + } + return token +} + +// accountSection is one My account contribution as the admin API lists it. +type accountSection struct { + Name string `json:"name"` + Status string `json:"status"` + Actions []struct { + Name string `json:"name"` + } `json:"actions"` +} + +func (s accountSection) actions() []string { + out := []string{} + for _, a := range s.Actions { + out = append(out, a.Name) + } + return out +} + +// frontendSection lists My account and returns the frontend_account section. +func (e *adminEnv) frontendSection(t *testing.T, token string) accountSection { + t.Helper() + var sections []accountSection + adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI("/account/contributions"), token, nil), §ions) + for _, s := range sections { + if s.Name == "frontend_account" { + return s + } + } + t.Fatalf("My account has no frontend_account section: %+v", sections) + return accountSection{} +} + +var linkURL = regexp.MustCompile(`^https://app\.example\.test/link-admin\?code=([a-f0-9]{64})$`) + +// startLink runs My account's link action and returns the code in its URL. +func (e *adminEnv) startLink(t *testing.T, token string) string { + t.Helper() + var out struct { + Message string `json:"message"` + URL string `json:"url"` + } + adminInto(t, e.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), token, map[string]any{}), &out) + m := linkURL.FindStringSubmatch(out.URL) + if m == nil || out.Message == "" { + t.Fatalf("link action answered %+v", out) + } + return m[1] +} + +// confirmLink posts the code as the frontend session token. +func (e *adminEnv) confirmLink(t *testing.T, userToken, code string) (int, string) { + t.Helper() + rec := e.call(t, http.MethodPost, "/_user/api/v1/backend-link/confirm", userToken, map[string]string{"code": code}) + return rec.Code, rec.Body.String() +} + +// TestBackendLinkSmoke: an administrator starts a link on My account, the +// site shows who asked, a session older than the request is refused, a +// fresh one links the accounts (the published resolver answers), the code is +// one-shot, the 1:1 rule holds, unlink and deleting the frontend user remove +// the link, and the subject events fire. +func TestBackendLinkSmoke(t *testing.T) { + env := newAdminEnv(t) + spy := env.spySubjects() + frontend := env.seedUser(t, "linker", nil) + other := env.seedUser(t, "linker-other", nil) + token := env.admin(t) + id := adminID(t, token) + login := env.adminLogin(t, id) + links, ok := env.app.Lookup[*classes.BackendLinks]() + if !ok || links == nil { + t.Fatal("*classes.BackendLinks is not published") + } + resolver, ok := env.app.Lookup[pact.MCPLinkedBackendResolver]() + if !ok || resolver == nil { + t.Fatal("pact.MCPLinkedBackendResolver is not published") + } + ctx := context.Background() + + section := env.frontendSection(t, token) + if section.Status != "Not linked" || !slices.Equal(section.actions(), []string{"link"}) { + t.Fatalf("unlinked section = %+v", section) + } + + code := env.startLink(t, token) + rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil) + var shown struct { + Data struct { + Admin map[string]any `json:"admin"` + ExpiresAt string `json:"expires_at"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil { + t.Fatal(err) + } + if shown.Data.Admin["login"] != login || shown.Data.Admin["name"] != login || len(shown.Data.Admin) != 2 { + t.Fatalf("show = %s", rec.Body.String()) + } + if expires, err := time.Parse(time.RFC3339, shown.Data.ExpiresAt); err != nil || time.Until(expires) < 9*time.Minute || time.Until(expires) > 11*time.Minute { + t.Fatalf("expires_at = %q (%v)", shown.Data.ExpiresAt, err) + } + env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+strings.Repeat("0", 64), "", nil) + + // A session signed in before the request is refused: sign in again. + stale := craftToken(t, strconv.FormatUint(uint64(frontend.ID), 10), "stale-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(time.Hour)) + if status, body := env.confirmLink(t, stale, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) { + t.Fatalf("stale confirm = %d %s", status, body) + } + if status, _ := env.confirmLink(t, "", code); status != http.StatusUnauthorized { + t.Fatalf("anonymous confirm = %d", status) + } + + spy.reset() + status, body := env.confirmLink(t, freshUserToken(t, frontend), code) + if status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) { + t.Fatalf("fresh confirm = %d %s", status, body) + } + linked, found, err := resolver.LinkedBackendUser(ctx, frontend.ID) + if err != nil || !found || linked.ID != id || linked.Login != login || linked.DisplayName != login { + t.Fatalf("LinkedBackendUser = %+v %v %v", linked, found, err) + } + if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) { + t.Fatalf("confirm fired %+v", spy.events) + } + var row models.BackendLink + if err := env.gdb.Where("user_id = ?", frontend.ID).Take(&row).Error; err != nil || row.BackendUserID != id || row.LinkedBy != nil { + t.Fatalf("link row = %+v %v", row, err) + } + section = env.frontendSection(t, token) + if !strings.Contains(section.Status, frontend.Email) || !slices.Equal(section.actions(), []string{"unlink"}) { + t.Fatalf("linked section = %+v", section) + } + + // The code is one-shot. + if status, _ := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusNotFound { + t.Fatalf("replayed confirm = %d", status) + } + env.expect(t, http.StatusNotFound, http.MethodGet, "/_user/api/v1/backend-link/"+code, "", nil) + + // One link per administrator: another frontend user cannot take it. + second, _, err := links.CreateLinkRequest(ctx, id) + if err != nil { + t.Fatal(err) + } + if status, body := env.confirmLink(t, freshUserToken(t, other), second); status != http.StatusConflict || !strings.Contains(body, `"already_linked"`) { + t.Fatalf("second link = %d %s", status, body) + } + + // Unlink on My account. + spy.reset() + env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/unlink"), token, map[string]any{}) + if _, found, err := resolver.LinkedBackendUser(ctx, frontend.ID); err != nil || found { + t.Fatalf("after unlink LinkedBackendUser found=%v err=%v", found, err) + } + if !spy.has("user", frontend.ID, pact.MCPSubjectLink) || !spy.has("backend", id, pact.MCPSubjectLink) { + t.Fatalf("unlink fired %+v", spy.events) + } + if section := env.frontendSection(t, token); !slices.Equal(section.actions(), []string{"link"}) { + t.Fatalf("section after unlink = %+v", section) + } + + // An expired request answers 404 like an unknown one. + expired, _, err := links.CreateLinkRequest(ctx, id) + if err != nil { + t.Fatal(err) + } + if err := env.gdb.Exec(`UPDATE golem15_user_backend_link_requests SET expires_at = NOW() - INTERVAL '1 minute' WHERE backend_user_id = ? AND used_at IS NULL`, id).Error; err != nil { + t.Fatal(err) + } + if status, _ := env.confirmLink(t, freshUserToken(t, frontend), expired); status != http.StatusNotFound { + t.Fatalf("expired confirm = %d", status) + } + + // Deleting the frontend user removes the link and ends its subject. + code = env.startLink(t, token) + if status, body := env.confirmLink(t, freshUserToken(t, frontend), code); status != http.StatusOK { + t.Fatalf("relink = %d %s", status, body) + } + spy.reset() + manager := env.admin(t, permAccessUsers) + env.expect(t, http.StatusOK, http.MethodDelete, userPath(frontend.ID, ""), manager, nil) + var left int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_backend_links WHERE backend_user_id = ?`, id).Scan(&left).Error; err != nil || left != 0 { + t.Fatalf("links left after delete = %d %v", left, err) + } + if !spy.has("user", frontend.ID, pact.MCPSubjectDeleted) || !spy.has("backend", id, pact.MCPSubjectLink) { + t.Fatalf("delete fired %+v", spy.events) + } +} diff --git a/classes/backend_links.go b/classes/backend_links.go new file mode 100644 index 0000000..038f93a --- /dev/null +++ b/classes/backend_links.go @@ -0,0 +1,507 @@ +package classes + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Errors of the account link service. A request that is unknown, used or +// expired is reported apart so callers can log the difference; the site +// routes answer all three with the same 404. +var ( + ErrLinkRequestNotFound = errors.New("golem15.user: backend link request not found") + ErrLinkRequestUsed = errors.New("golem15.user: backend link request already used") + ErrLinkRequestExpired = errors.New("golem15.user: backend link request expired") + ErrStaleSession = errors.New("golem15.user: the session was signed in before the link request") + ErrAlreadyLinked = errors.New("golem15.user: one of the accounts is already linked") + ErrBackendUserUnavailable = errors.New("golem15.user: the administrator is missing or not activated") + ErrFrontendUserUnavailable = errors.New("golem15.user: the frontend user is missing or deactivated") +) + +// DefaultBackendLinkRequestTTL is how long a link request stays usable when +// golem15.user.backend_links.request_ttl is not set. +const DefaultBackendLinkRequestTTL = 10 * time.Minute + +var _ pact.MCPLinkedBackendResolver = (*BackendLinks)(nil) + +// BackendLinks is the verified link between a frontend user and a backend +// administrator (D-03): one link per account on each side, written either +// after the frontend user proves the account with a fresh sign-in +// (CreateLinkRequest, then ConfirmLinkRequest) or by an administrator with +// golem15.users.manage_backend_links on the Administrators screen (SetLink). +// Boot publishes it as *BackendLinks and as pact.MCPLinkedBackendResolver. +// +// Every read and write uses the transaction cabana runs a hook or an action +// in when the context carries one (cabana.TxFromContext), else the +// application's database. +type BackendLinks struct { + App *backpack.App +} + +// BackendLinkRequestTTL reads golem15.user.backend_links.request_ttl, a Go +// duration (10m by default). A value that does not parse or is not positive +// is an error, so a typo fails the boot instead of silently changing the +// lifetime. +func BackendLinkRequestTTL(app *backpack.App) (time.Duration, error) { + if app == nil || app.Config == nil { + return DefaultBackendLinkRequestTTL, nil + } + raw := strings.TrimSpace(app.Config.String("golem15.user.backend_links.request_ttl")) + if raw == "" { + return DefaultBackendLinkRequestTTL, nil + } + ttl, err := time.ParseDuration(raw) + if err != nil || ttl <= 0 { + return 0, fmt.Errorf("golem15.user: backend_links.request_ttl must be a positive duration such as 10m, got %q", raw) + } + return ttl, nil +} + +// FrontendURL is the site the plugin sends people to for pages it does not +// serve itself (the account link page): golem15.user.frontend_url, falling +// back to app.url, without a trailing slash. Empty when neither is set. +func FrontendURL(app *backpack.App) string { + if app == nil || app.Config == nil { + return "" + } + base := strings.TrimSpace(app.Config.String("golem15.user.frontend_url")) + if base == "" { + base = strings.TrimSpace(app.Config.String("app.url")) + } + return strings.TrimRight(base, "/") +} + +// db returns the handle a call works on: the cabana transaction on ctx, or +// the application's database. +func (s *BackendLinks) db(ctx context.Context) (*gorm.DB, error) { + if tx, ok := cabana.TxFromContext(ctx); ok { + return fresh(ctx, tx), nil + } + if s == nil || s.App == nil { + return nil, fmt.Errorf("golem15.user: app is nil") + } + gdb, ok := s.App.Lookup[*gorm.DB]() + if !ok || gdb == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + return gdb.WithContext(ctx), nil +} + +// hashLinkCode is the only form of a link code that is stored. +func hashLinkCode(plain string) string { + sum := sha256.Sum256([]byte(plain)) + return hex.EncodeToString(sum[:]) +} + +// validLinkCode reports whether plain has the shape CreateLinkRequest +// issues: 64 lower-case hex characters. +func validLinkCode(plain string) bool { + if len(plain) != 64 { + return false + } + for _, r := range plain { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { + return false + } + } + return true +} + +// CreateLinkRequest starts a link for the administrator: a one-shot code +// (32 random bytes, hex) valid for golem15.user.backend_links.request_ttl, +// stored as its SHA-256. The administrator's earlier requests that were not +// used are dropped, so only the newest code works. The plain code is +// returned once, for the site URL. +func (s *BackendLinks) CreateLinkRequest(ctx context.Context, backendUserID uint) (string, time.Time, error) { + if backendUserID == 0 { + return "", time.Time{}, ErrBackendUserUnavailable + } + ttl, err := BackendLinkRequestTTL(s.app()) + if err != nil { + return "", time.Time{}, err + } + db, err := s.db(ctx) + if err != nil { + return "", time.Time{}, err + } + plain, err := newCode() + if err != nil { + return "", time.Time{}, err + } + now := time.Now() + req := &models.BackendLinkRequest{ + CodeHash: hashLinkCode(plain), + BackendUserID: backendUserID, + ExpiresAt: now.Add(ttl), + CreatedAt: now, + } + if err := fresh(ctx, db).Where("backend_user_id = ? AND used_at IS NULL", backendUserID). + Delete(&models.BackendLinkRequest{}).Error; err != nil { + return "", time.Time{}, err + } + if err := fresh(ctx, db).Create(req).Error; err != nil { + return "", time.Time{}, err + } + return plain, req.ExpiresAt, nil +} + +// LinkRequestByCode returns a usable request and its administrator for the +// site's link page. An unknown, used or expired code is +// ErrLinkRequestNotFound, ErrLinkRequestUsed or ErrLinkRequestExpired; an +// administrator who was deactivated or deleted since is +// ErrBackendUserUnavailable. +func (s *BackendLinks) LinkRequestByCode(ctx context.Context, plain string) (*models.BackendLinkRequest, *cabana.BackendUser, error) { + if !validLinkCode(plain) { + return nil, nil, ErrLinkRequestNotFound + } + db, err := s.db(ctx) + if err != nil { + return nil, nil, err + } + var rows []models.BackendLinkRequest + if err := fresh(ctx, db).Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil { + return nil, nil, err + } + if len(rows) == 0 { + return nil, nil, ErrLinkRequestNotFound + } + req := &rows[0] + if err := requestUsable(req, time.Now()); err != nil { + return nil, nil, err + } + admin, err := activeBackendUser(ctx, db, req.BackendUserID) + if err != nil { + return nil, nil, err + } + return req, admin, nil +} + +func requestUsable(req *models.BackendLinkRequest, now time.Time) error { + if req.UsedAt != nil { + return ErrLinkRequestUsed + } + if !req.ExpiresAt.After(now) { + return ErrLinkRequestExpired + } + return nil +} + +// ConfirmLinkRequest stores the link between the signed-in frontend user and +// the administrator who created the request, in one transaction: the request +// row is locked, must be unused and unexpired, and the session must have +// been signed in at or after the request was created (sessionIssuedAt is +// the JWT iat, compared in whole seconds because iat has no fraction), so a +// browser that was already signed in has to sign in again (D-05). Neither +// account may be linked already (ErrAlreadyLinked) and the administrator +// must still be activated (ErrBackendUserUnavailable). The request is used +// up with the link; BackendLinkedEvent fires inside the transaction and the +// two pact.MCPSubjectChanged events after it commits. +func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionIssuedAt time.Time) (*models.BackendLink, error) { + if !validLinkCode(plain) { + return nil, ErrLinkRequestNotFound + } + if frontendUserID == 0 { + return nil, ErrFrontendUserUnavailable + } + gdb, err := s.pool() + if err != nil { + return nil, err + } + var link *models.BackendLink + err = lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error { + var rows []models.BackendLinkRequest + if err := fresh(ctx, tx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("code_hash = ?", hashLinkCode(plain)).Limit(1).Find(&rows).Error; err != nil { + return err + } + if len(rows) == 0 { + return ErrLinkRequestNotFound + } + req := &rows[0] + now := time.Now() + if err := requestUsable(req, now); err != nil { + return err + } + if sessionIssuedAt.IsZero() || sessionIssuedAt.Before(req.CreatedAt.Truncate(time.Second)) { + return ErrStaleSession + } + if _, err := activeBackendUser(ctx, tx, req.BackendUserID); err != nil { + return err + } + created, err := s.insertLink(ctx, tx, frontendUserID, req.BackendUserID, nil) + if err != nil { + return err + } + if err := fresh(ctx, tx).Model(&models.BackendLinkRequest{}).Where("id = ?", req.ID). + Update("used_at", now).Error; err != nil { + return err + } + link = created + return nil + }) + if err != nil { + return nil, err + } + return link, nil +} + +// SetLink stores a link an administrator sets on the Administrators screen +// (D-07): the frontend user must exist and not be deactivated +// (ErrFrontendUserUnavailable), the administrator must be activated +// (ErrBackendUserUnavailable) and neither may be linked already +// (ErrAlreadyLinked). linkedBy is recorded (0 records none). It runs in tx, +// fires BackendLinkedEvent there and the two pact.MCPSubjectChanged events +// after tx commits. +func (s *BackendLinks) SetLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID, linkedBy uint) (*models.BackendLink, error) { + if tx == nil { + return nil, fmt.Errorf("golem15.user: setting a link needs a transaction") + } + var users []models.User + if err := fresh(ctx, tx).Where("id = ?", frontendUserID).Limit(1).Find(&users).Error; err != nil { + return nil, err + } + if frontendUserID == 0 || len(users) == 0 { + return nil, ErrFrontendUserUnavailable + } + if _, err := activeBackendUser(ctx, tx, backendUserID); err != nil { + return nil, err + } + var by *uint + if linkedBy != 0 { + by = &linkedBy + } + return s.insertLink(ctx, tx, frontendUserID, backendUserID, by) +} + +// insertLink writes one link in tx after checking both sides are free; a +// concurrent writer that wins the unique index also ends in +// ErrAlreadyLinked. +func (s *BackendLinks) insertLink(ctx context.Context, tx *gorm.DB, frontendUserID, backendUserID uint, linkedBy *uint) (*models.BackendLink, error) { + var taken int64 + if err := fresh(ctx, tx).Model(&models.BackendLink{}). + Where("user_id = ? OR backend_user_id = ?", frontendUserID, backendUserID). + Count(&taken).Error; err != nil { + return nil, err + } + if taken > 0 { + return nil, ErrAlreadyLinked + } + link := &models.BackendLink{UserID: frontendUserID, BackendUserID: backendUserID, LinkedBy: linkedBy, CreatedAt: time.Now()} + if err := fresh(ctx, tx).Create(link).Error; err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, ErrAlreadyLinked + } + return nil, err + } + app := s.app() + if app != nil && app.Events != nil { + if err := app.Events.Fire(ctx, &BackendLinkedEvent{Link: link, Tx: tx}); err != nil { + return nil, fmt.Errorf("%s: %w", BackendLinkedEventName, err) + } + } + FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink) + FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink) + return link, nil +} + +// RemoveLink deletes link in tx, fires BackendUnlinkedEvent there and the +// two pact.MCPSubjectChanged link events after tx commits. A link that is +// already gone is not an error and fires nothing. +func (s *BackendLinks) RemoveLink(ctx context.Context, tx *gorm.DB, link *models.BackendLink) error { + if link == nil || link.ID == 0 { + return nil + } + if tx == nil { + return fmt.Errorf("golem15.user: removing a link needs a transaction") + } + res := fresh(ctx, tx).Where("id = ?", link.ID).Delete(&models.BackendLink{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil + } + app := s.app() + if app != nil && app.Events != nil { + if err := app.Events.Fire(ctx, &BackendUnlinkedEvent{Link: link, Tx: tx}); err != nil { + return fmt.Errorf("%s: %w", BackendUnlinkedEventName, err) + } + } + FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardUser, link.UserID, pact.MCPSubjectLink) + FireSubjectChangedAfterCommit(ctx, app, tx, SubjectGuardBackend, link.BackendUserID, pact.MCPSubjectLink) + return nil +} + +// LinkFor returns the link of the administrator, or nil. +func (s *BackendLinks) LinkFor(ctx context.Context, backendUserID uint) (*models.BackendLink, error) { + return s.linkWhere(ctx, "backend_user_id = ?", backendUserID) +} + +// LinkOf returns the link of the frontend user, or nil. +func (s *BackendLinks) LinkOf(ctx context.Context, frontendUserID uint) (*models.BackendLink, error) { + return s.linkWhere(ctx, "user_id = ?", frontendUserID) +} + +func (s *BackendLinks) linkWhere(ctx context.Context, where string, id uint) (*models.BackendLink, error) { + if id == 0 { + return nil, nil + } + db, err := s.db(ctx) + if err != nil { + return nil, err + } + var rows []models.BackendLink + if err := fresh(ctx, db).Where(where, id).Limit(1).Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + return &rows[0], nil +} + +// LinkedBackendUser implements pact.MCPLinkedBackendResolver with one +// indexed query per call (no cache): the administrator linked to the +// frontend user, false when there is no link, when the frontend user is +// deactivated, or when the administrator is deactivated or deleted. +func (s *BackendLinks) LinkedBackendUser(ctx context.Context, frontendUserID uint) (pact.LinkedBackendUser, bool, error) { + if frontendUserID == 0 { + return pact.LinkedBackendUser{}, false, nil + } + db, err := s.db(ctx) + if err != nil { + return pact.LinkedBackendUser{}, false, err + } + var rows []struct { + ID uint + Login string + FirstName *string + LastName *string + } + err = fresh(ctx, db).Raw(`SELECT b.id, b.login, b.first_name, b.last_name +FROM golem15_user_backend_links l +JOIN backend_users b ON b.id = l.backend_user_id +JOIN users u ON u.id = l.user_id +WHERE l.user_id = ? AND b.deleted_at IS NULL AND b.is_activated = TRUE AND u.deleted_at IS NULL +LIMIT 1`, frontendUserID).Scan(&rows).Error + if err != nil { + return pact.LinkedBackendUser{}, false, err + } + if len(rows) == 0 { + return pact.LinkedBackendUser{}, false, nil + } + row := rows[0] + return pact.LinkedBackendUser{ID: row.ID, Login: row.Login, DisplayName: backendDisplayName(deref(row.FirstName), deref(row.LastName), row.Login)}, true, nil +} + +// BackendDisplayName is the name the link pages show for an administrator: +// first and last name, else the login. +func BackendDisplayName(admin *cabana.BackendUser) string { + if admin == nil { + return "" + } + return backendDisplayName(admin.FirstName, admin.LastName, admin.Login) +} + +func backendDisplayName(first, last, login string) string { + if name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last)); name != "" { + return name + } + return login +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +// activeBackendUser loads an activated, not deleted administrator, else +// ErrBackendUserUnavailable. +func activeBackendUser(ctx context.Context, db *gorm.DB, id uint) (*cabana.BackendUser, error) { + if id == 0 { + return nil, ErrBackendUserUnavailable + } + var rows []cabana.BackendUser + if err := fresh(ctx, db).Where("id = ? AND is_activated = TRUE", id).Limit(1).Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, ErrBackendUserUnavailable + } + return &rows[0], nil +} + +// RegisterBackendLinkListeners removes a frontend user's link inside the +// transaction that deletes (DeletingEvent) or deactivates +// (DeactivatingEvent) the user, and fires pact.MCPSubjectChanged deleted +// for the user and link for the administrator after it commits. +func (s *BackendLinks) RegisterBackendLinkListeners(app *backpack.App) { + if app == nil || app.Events == nil { + return + } + const owner = "golem15.user.backend_links" + app.Events.Listen(owner, func(ctx context.Context, e *DeletingEvent) error { + if e == nil { + return nil + } + return s.dropUserLink(ctx, e.Tx, e.User) + }) + app.Events.Listen(owner, func(ctx context.Context, e *DeactivatingEvent) error { + if e == nil { + return nil + } + return s.dropUserLink(ctx, e.Tx, e.User) + }) +} + +func (s *BackendLinks) dropUserLink(ctx context.Context, tx *gorm.DB, user *models.User) error { + if user == nil || user.ID == 0 || tx == nil { + return nil + } + var rows []models.BackendLink + if err := fresh(ctx, tx).Where("user_id = ?", user.ID).Limit(1).Find(&rows).Error; err != nil { + return err + } + if len(rows) > 0 { + if err := s.RemoveLink(ctx, tx, &rows[0]); err != nil { + return err + } + } + FireSubjectChangedAfterCommit(ctx, s.app(), tx, SubjectGuardUser, user.ID, pact.MCPSubjectDeleted) + return nil +} + +func (s *BackendLinks) app() *backpack.App { + if s == nil { + return nil + } + return s.App +} + +func (s *BackendLinks) pool() (*gorm.DB, error) { + if s == nil || s.App == nil { + return nil, fmt.Errorf("golem15.user: app is nil") + } + gdb, ok := s.App.Lookup[*gorm.DB]() + if !ok || gdb == nil { + return nil, fmt.Errorf("golem15.user: gorm handle is missing") + } + return gdb, nil +} diff --git a/classes/events.go b/classes/events.go index 5882539..5c3a3bc 100644 --- a/classes/events.go +++ b/classes/events.go @@ -14,6 +14,12 @@ const DeletingEventName = "user.Deleting" // DeactivatingEventName is the festival name of DeactivatingEvent. const DeactivatingEventName = "user.Deactivating" +// BackendLinkedEventName is the festival name of BackendLinkedEvent. +const BackendLinkedEventName = "user.BackendLinked" + +// BackendUnlinkedEventName is the festival name of BackendUnlinkedEvent. +const BackendUnlinkedEventName = "user.BackendUnlinked" + // GetApiArrayEvent is the fire-and-collect payload other plugins extend. type GetApiArrayEvent struct { User *models.User @@ -67,3 +73,20 @@ type DeactivatingEvent struct { User *models.User Tx *gorm.DB } + +// BackendLinkedEvent fires inside the write transaction after a link was +// stored, by the frontend user's confirmation (Link.LinkedBy nil) or by an +// administrator (Link.LinkedBy set). A listener error rolls the link back. +type BackendLinkedEvent struct { + Link *models.BackendLink + Tx *gorm.DB +} + +// BackendUnlinkedEvent fires inside the write transaction after a link was +// removed, from My account, the Administrators screen or because one of the +// accounts was deleted or deactivated. A listener error rolls the removal +// back. +type BackendUnlinkedEvent struct { + Link *models.BackendLink + Tx *gorm.DB +} diff --git a/classes/subject_events.go b/classes/subject_events.go new file mode 100644 index 0000000..4820691 --- /dev/null +++ b/classes/subject_events.go @@ -0,0 +1,55 @@ +package classes + +import ( + "context" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// Guard names the plugin's pact.MCPSubjectChanged events carry: the frontend +// user guard this plugin provides and the admin's guard (for the backend +// side of an account link). +const ( + SubjectGuardUser = "user" + SubjectGuardBackend = "backend" +) + +// FireSubjectChanged fires one *pact.MCPSubjectChanged (guard, user id, +// kind) through the application's event bus at once, so the MCP endpoint +// re-lists the subject's tools or ends its sessions. A missing application +// or bus, or a zero id, is a no-op; a listener's error is logged and never +// returned, so the write that caused the event never fails because of it +// (every MCP request re-checks anyway). +func FireSubjectChanged(ctx context.Context, app *backpack.App, guard string, userID uint, kind string) { + if app == nil || app.Events == nil || userID == 0 || kind == "" { + return + } + if ctx == nil { + ctx = context.Background() + } + event := &pact.MCPSubjectChanged{Guard: guard, UserID: userID, Kind: kind} + if err := app.Events.Fire(ctx, event); err != nil { + pluginLog.WarnContext(ctx, "golem15.user: subject changed listener failed", "guard", guard, "user_id", userID, "kind", kind, "err", err) + } +} + +// FireSubjectChangedAfterCommit is FireSubjectChanged once the write +// transaction tx commits (lagoon.AfterCommit), so a listener that reads the +// database sees the change; nothing is fired when the transaction rolls +// back. A nil tx fires at once. ctx must be the context the transaction +// runs under (the one cabana hands a hook or an action). +func FireSubjectChangedAfterCommit(ctx context.Context, app *backpack.App, tx *gorm.DB, guard string, userID uint, kind string) { + if app == nil || app.Events == nil || userID == 0 || kind == "" { + return + } + if tx == nil { + FireSubjectChanged(ctx, app, guard, userID, kind) + return + } + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { + FireSubjectChanged(ctx, app, guard, userID, kind) + }) +} diff --git a/config/config.yaml b/config/config.yaml index cfca0f8..19b9033 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -38,3 +38,10 @@ privileged_groups: # refuses them. The turnstile:* middleware comes from the framework (summercms # v0.3.0 and later); its secret is turnstile.secret (SUMMER_TURNSTILE__SECRET). turnstile: "" +# The site this plugin's links point to (the account link page +# /link-admin?code=...). Empty falls back to app.url. +frontend_url: "" +backend_links: + # How long a link request an administrator starts on My account stays + # usable (a Go duration). The code is one-shot. + request_ttl: 10m diff --git a/controllers/backend_link_controller.go b/controllers/backend_link_controller.go new file mode 100644 index 0000000..b29d58d --- /dev/null +++ b/controllers/backend_link_controller.go @@ -0,0 +1,118 @@ +package controllers + +import ( + "errors" + "net/http" + "strconv" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" +) + +// backendLinks returns the published link service, or one over app. +func backendLinks(app *backpack.App) *classes.BackendLinks { + if app != nil { + if links, ok := app.Lookup[*classes.BackendLinks](); ok && links != nil { + return links + } + } + return &classes.BackendLinks{App: app} +} + +// linkNotFound is the one answer for an unknown, used or expired code and +// for an administrator who is no longer available: the page cannot tell +// them apart. +func linkNotFound(w http.ResponseWriter) { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) +} + +// BackendLinkShow serves GET /_user/api/v1/backend-link/{code}, the site's +// "link this account to the administrator?" page: the administrator's login +// and display name and when the code expires, nothing else. Anyone holding +// the code may read it; a code that is unknown, used or expired, or whose +// administrator was deactivated, answers 404. +func BackendLinkShow(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, admin, err := backendLinks(app).LinkRequestByCode(r.Context(), r.PathValue("code")) + if err != nil { + if isLinkGone(err) { + linkNotFound(w) + return + } + writeOpaque500(w) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "admin": map[string]any{"login": admin.Login, "name": classes.BackendDisplayName(admin)}, + "expires_at": req.ExpiresAt.UTC().Format(time.RFC3339), + }}) + } +} + +// BackendLinkConfirm serves POST /_user/api/v1/backend-link/confirm behind +// jwt.auth: body {"code": ...}. The signed-in frontend user is linked to the +// administrator who created the code, only when the session token was +// issued at or after the request (the user signed in fresh for it): an +// older session answers 409 with the stale-session error, an account already linked on +// either side 409 already_linked, an unknown, used or expired code 404. +func BackendLinkConfirm(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + principal, ok := bouncer.User(r.Context()) + if !ok || principal == nil || principal.ID == 0 { + writeUnauthorized(w) + return + } + secret, err := classes.JWTSecret(app) + if err != nil { + writeOpaque500(w) + return + } + // The token jwt.auth accepted (bearer header, then the session + // cookies), read again for its iat. + sub, iat, _, _, err := bouncer.VerifyClaims(classes.SessionToken(r), secret) + if err != nil || sub != strconv.FormatUint(uint64(principal.ID), 10) { + writeUnauthorized(w) + return + } + fields, err := readFields(r) + if err != nil { + linkNotFound(w) + return + } + links := backendLinks(app) + _, err = links.ConfirmLinkRequest(r.Context(), strField(fields, "code"), principal.ID, iat) + switch { + case err == nil: + case errors.Is(err, classes.ErrStaleSession): + writeJSON(w, http.StatusConflict, map[string]any{"error": "stale_session"}) + return + case errors.Is(err, classes.ErrAlreadyLinked): + writeJSON(w, http.StatusConflict, map[string]any{"error": "already_linked"}) + return + case isLinkGone(err): + linkNotFound(w) + return + default: + writeOpaque500(w) + return + } + linked, found, err := links.LinkedBackendUser(r.Context(), principal.ID) + if err != nil || !found { + writeOpaque500(w) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "backend_user": map[string]any{"login": linked.Login, "name": linked.DisplayName}, + }}) + } +} + +func isLinkGone(err error) bool { + return errors.Is(err, classes.ErrLinkRequestNotFound) || + errors.Is(err, classes.ErrLinkRequestUsed) || + errors.Is(err, classes.ErrLinkRequestExpired) || + errors.Is(err, classes.ErrBackendUserUnavailable) || + errors.Is(err, classes.ErrFrontendUserUnavailable) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index caa9dd0..863a2fb 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -115,3 +115,23 @@ organisation: empty_organisation: — No organisation — oauth: last_method_blocked: This is the only remaining way to sign in. Link another method before disconnecting this one. +backend_link: + section: Frontend account + section_comment: Link your administrator account to your account on the site, so tools you use there can act as this administrator. You sign in on the site to prove the account is yours. + status_linked: "Linked to :email" + status_unlinked: Not linked + link: Link frontend account + link_started: Open the link and sign in on the site with the account to link. The link works once, for a few minutes. + unlink: Unlink frontend account + unlink_confirm: Unlink your frontend account? Tools used through it will no longer act as this administrator. + unlinked: The frontend account is unlinked. + prompt_email: Frontend account email + prompt_comment: The exact email of the frontend user to link to this administrator. + set: Link frontend account + set_confirm: Link a frontend account to this administrator? Tools that user runs will act as this administrator. + set_success: The frontend account is linked. + remove: Unlink frontend account + remove_confirm: Unlink the frontend account from this administrator? + remove_success: The frontend account is unlinked. + unknown_user: No active frontend user has this email. + already_linked: This administrator or this frontend user is already linked. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 67d381c..8445c67 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -115,3 +115,23 @@ organisation: empty_organisation: — Brak organizacji — oauth: last_method_blocked: To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę. +backend_link: + section: Konto na stronie + section_comment: Połącz konto administratora ze swoim kontem na stronie, aby narzędzia używane tam mogły działać jako ten administrator. Aby potwierdzić, że konto jest Twoje, zaloguj się na stronie. + status_linked: "Połączone z :email" + status_unlinked: Niepołączone + link: Połącz konto na stronie + link_started: Otwórz link i zaloguj się na stronie kontem, które chcesz połączyć. Link działa raz, przez kilka minut. + unlink: Odłącz konto na stronie + unlink_confirm: Odłączyć konto na stronie? Narzędzia używane przez nie przestaną działać jako ten administrator. + unlinked: Konto na stronie zostało odłączone. + prompt_email: Email konta na stronie + prompt_comment: Dokładny email użytkownika strony, którego połączyć z tym administratorem. + set: Połącz konto na stronie + set_confirm: Połączyć konto na stronie z tym administratorem? Narzędzia uruchamiane przez tego użytkownika będą działać jako ten administrator. + set_success: Konto na stronie zostało połączone. + remove: Odłącz konto na stronie + remove_confirm: Odłączyć konto na stronie od tego administratora? + remove_success: Konto na stronie zostało odłączone. + unknown_user: Żaden aktywny użytkownik strony nie ma tego emaila. + already_linked: Ten administrator albo ten użytkownik strony jest już połączony. diff --git a/models/backend_link.go b/models/backend_link.go new file mode 100644 index 0000000..260ba97 --- /dev/null +++ b/models/backend_link.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// BackendLink is the verified link between one frontend user and one backend +// administrator (golem15_user_backend_links). Each side appears in at most +// one link. LinkedBy is the administrator who set the link on the +// Administrators screen; it is nil for a link the frontend user confirmed +// themselves after the administrator started it on My account. +type BackendLink struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + BackendUserID uint `gorm:"column:backend_user_id"` + LinkedBy *uint `gorm:"column:linked_by"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (BackendLink) TableName() string { return "golem15_user_backend_links" } + +func (BackendLink) Fillable() []string { + return []string{"user_id", "backend_user_id", "linked_by"} +} + +func init() { Register(BackendLink{}) } diff --git a/models/backend_link_request.go b/models/backend_link_request.go new file mode 100644 index 0000000..ec2c12a --- /dev/null +++ b/models/backend_link_request.go @@ -0,0 +1,31 @@ +package models + +import "time" + +// BackendLinkRequest is a one-shot request an administrator starts to link +// their account to a frontend user (golem15_user_backend_link_requests). +// Only the SHA-256 of the code is stored; the plain code travels once, in +// the site URL the administrator opens. +type BackendLinkRequest struct { + ID uint `gorm:"column:id;primaryKey"` + CodeHash string `gorm:"column:code_hash" json:"-"` + BackendUserID uint `gorm:"column:backend_user_id"` + ExpiresAt time.Time `gorm:"column:expires_at"` + UsedAt *time.Time `gorm:"column:used_at"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (BackendLinkRequest) TableName() string { return "golem15_user_backend_link_requests" } + +func (BackendLinkRequest) Fillable() []string { + return []string{"code_hash", "backend_user_id", "expires_at", "used_at"} +} + +func (BackendLinkRequest) Hidden() []string { return []string{"code_hash"} } + +// IsUsableAt is false once the request was used or has expired. +func (r BackendLinkRequest) IsUsableAt(now time.Time) bool { + return r.UsedAt == nil && r.ExpiresAt.After(now) +} + +func init() { Register(BackendLinkRequest{}) } diff --git a/password_routes_test.go b/password_routes_test.go index 767c97d..e054f09 100644 --- a/password_routes_test.go +++ b/password_routes_test.go @@ -95,8 +95,10 @@ func TestPasswordPublicRoutes(t *testing.T) { if err != nil { t.Fatalf("unset: Routes error %v", err) } - if len(base) != 18 { - t.Fatalf("unset: %d routes, want 18", len(base)) + // 18 routes up to v0.1.9, plus GET /backend-link/{code} and POST + // /backend-link/confirm (the account link). + if len(base) != 20 { + t.Fatalf("unset: %d routes, want 20", len(base)) } seen := 0 for _, route := range base { @@ -108,8 +110,8 @@ func TestPasswordPublicRoutes(t *testing.T) { t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys)) } off := withoutPasswordRoutes(base) - if len(off) != 15 { - t.Fatalf("base without the password routes has %d routes, want 15", len(off)) + if len(off) != 17 { + t.Fatalf("base without the password routes has %d routes, want 17", len(off)) } for _, c := range []struct { diff --git a/plugin.go b/plugin.go index cbf9cc8..2be47f8 100644 --- a/plugin.go +++ b/plugin.go @@ -107,9 +107,31 @@ func (p *Plugin) Boot(app *backpack.App) error { }); err != nil { return err } + if err := p.bootBackendLinks(app); err != nil { + return err + } return classes.RegisterUserAuthorizer(app) } +// bootBackendLinks publishes the account link service as *classes.BackendLinks +// and as the MCP linked-backend resolver, and removes a frontend user's link +// when the user is deleted or deactivated. The request lifetime is checked +// here so a bad golem15.user.backend_links.request_ttl fails the boot. +func (p *Plugin) bootBackendLinks(app *backpack.App) error { + if _, err := classes.BackendLinkRequestTTL(app); err != nil { + return err + } + links := &classes.BackendLinks{App: app} + if err := app.Publish(links); err != nil { + return err + } + if err := app.Publish[pact.MCPLinkedBackendResolver](links); err != nil { + return err + } + links.RegisterBackendLinkListeners(app) + return nil +} + func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) { interval := 10 * time.Minute if p.app != nil && p.app.Config != nil { diff --git a/routes.go b/routes.go index d42c780..615605a 100644 --- a/routes.go +++ b/routes.go @@ -42,6 +42,9 @@ func (p *Plugin) Routes(r pact.Router) error { g.Get("/tokens", controllers.APITokenIndex(p.app), "jwt.auth") g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth") g.Where("id", "[0-9]+") + g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app)) + g.Where("code", "[a-f0-9]{64}") + g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth") }) return nil } diff --git a/updates/202610220001_create_backend_links.go b/updates/202610220001_create_backend_links.go new file mode 100644 index 0000000..d8a9382 --- /dev/null +++ b/updates/202610220001_create_backend_links.go @@ -0,0 +1,60 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// backendLinkMigrations create the verified frontend-to-backend account link +// (one frontend user to one administrator, both sides unique) and the +// one-shot link requests an administrator starts on My account. A request +// stores only the SHA-256 of its code. Deleting either account removes its +// link and requests; deleting the administrator who set a link keeps the +// link and clears linked_by. +// +// The foreign keys to backend_users are added when that table exists, which +// it always does when lagoon.Migrate runs the plugin (the admin's own +// migrations run first); a run of the plugin's migrations alone, as some +// tests do, creates the tables without them. +var backendLinkMigrations = []*gormigrate.Migration{ + { + ID: "202610220001_create_golem15_user_backend_links", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE golem15_user_backend_links ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + backend_user_id INTEGER NOT NULL UNIQUE, + linked_by INTEGER NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TABLE golem15_user_backend_link_requests ( + id BIGSERIAL PRIMARY KEY, + code_hash CHAR(64) NOT NULL UNIQUE, + backend_user_id INTEGER NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX golem15_user_backend_link_requests_backend_user_id_index ON golem15_user_backend_link_requests (backend_user_id); +DO $$ +BEGIN + IF to_regclass('backend_users') IS NOT NULL THEN + ALTER TABLE golem15_user_backend_links + ADD CONSTRAINT golem15_user_backend_links_backend_user_id_foreign + FOREIGN KEY (backend_user_id) REFERENCES backend_users(id) ON DELETE CASCADE, + ADD CONSTRAINT golem15_user_backend_links_linked_by_foreign + FOREIGN KEY (linked_by) REFERENCES backend_users(id) ON DELETE SET NULL; + ALTER TABLE golem15_user_backend_link_requests + ADD CONSTRAINT golem15_user_backend_link_requests_backend_user_id_foreign + FOREIGN KEY (backend_user_id) REFERENCES backend_users(id) ON DELETE CASCADE; + END IF; +END +$$`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS golem15_user_backend_link_requests; DROP TABLE IF EXISTS golem15_user_backend_links").Error + }, + }, +} + +func init() { Register(backendLinkMigrations...) } diff --git a/updates/organisations_test.go b/updates/organisations_test.go index 1569f26..05bf4f4 100644 --- a/updates/organisations_test.go +++ b/updates/organisations_test.go @@ -52,6 +52,7 @@ func TestCreateOrganisationsMigration(t *testing.T) { "202610050001_create_oauth_identities", "202610160002_add_handle_to_users", "202610210001_add_users_email_verified_at", + "202610220001_create_golem15_user_backend_links", } if len(ids) != len(want) { t.Fatalf("history = %v", ids) @@ -62,9 +63,9 @@ func TestCreateOrganisationsMigration(t *testing.T) { } } - // Email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, - // throttle, extend-users, then organisations. - for range 12 { + // Backend links, email proof, handle, OAuth identities, the three admin migrations, API tokens, user groups, + // jwt blacklist, throttle, extend-users, then organisations. + for range 13 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } diff --git a/updates/user_groups_test.go b/updates/user_groups_test.go index 968211c..856c80d 100644 --- a/updates/user_groups_test.go +++ b/updates/user_groups_test.go @@ -54,8 +54,8 @@ func TestUserGroupsMigration(t *testing.T) { t.Fatalf("users_groups primary key = %q", pk) } - // Email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups. - for range 8 { + // Backend links, email verified at, handle, OAuth identities, frontend permissions, last seen, API tokens, user permissions, then user groups. + for range 9 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } diff --git a/updates/user_session_test.go b/updates/user_session_test.go index cc5fe3f..9b6ed08 100644 --- a/updates/user_session_test.go +++ b/updates/user_session_test.go @@ -47,9 +47,9 @@ func TestExtendUsersMigration(t *testing.T) { if !row.HasSelfSetPassword || row.IsActivated || row.MarketingConsent || row.IsOnboarded { t.Fatalf("defaults = %+v", row) } - // Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, - // throttle, then extend-users. - for range 11 { + // Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, + // jwt blacklist, throttle, then extend-users. + for range 12 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } @@ -88,8 +88,8 @@ func TestCreateUserThrottleMigration(t *testing.T) { t.Fatalf("missing column %s", col) } } - // Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle. - for range 10 { + // Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, jwt blacklist, then throttle. + for range 11 { if err := m.RollbackLast(); err != nil { t.Fatal(err) } @@ -120,8 +120,8 @@ func TestCreateJwtBlacklistMigration(t *testing.T) { t.Fatalf("missing column %s", col) } } - // Email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist. - for range 9 { + // Backend links, email verified at, handle, OAuth identities, the three admin migrations, API tokens, user groups, then jwt blacklist. + for range 10 { if err := m.RollbackLast(); err != nil { t.Fatal(err) }