feat: verified frontend account link for administrators

- golem15_user_backend_links (1:1, both sides unique) and one-shot hashed
  golem15_user_backend_link_requests (golem15.user.backend_links.request_ttl, 10m)
- classes.BackendLinks: CreateLinkRequest, ConfirmLinkRequest (row lock,
  JWT iat not before the request), SetLink, RemoveLink, LinkedBackendUser;
  published as *classes.BackendLinks and pact.MCPLinkedBackendResolver
- GET /_user/api/v1/backend-link/{code} and POST /backend-link/confirm (jwt.auth)
- My account section frontend_account with link and unlink
- BackendLinkedEvent, BackendUnlinkedEvent and pact.MCPSubjectChanged after commit;
  deleting or deactivating a frontend user removes its link
- golem15.user.frontend_url (falls back to app.url)
This commit is contained in:
Jakub Zych
2026-10-23 12:03:26 +02:00
parent 3828a65481
commit 7ac80c9aea
18 changed files with 1323 additions and 16 deletions

142
account_contributions.go Normal file
View File

@@ -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 <email>" 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=<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
}

272
backend_link_smoke_test.go Normal file
View File

@@ -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), &sections)
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)
}
}

507
classes/backend_links.go Normal file
View File

@@ -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
}

View File

@@ -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
}

55
classes/subject_events.go Normal file
View File

@@ -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)
})
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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.

24
models/backend_link.go Normal file
View File

@@ -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{}) }

View File

@@ -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{}) }

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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...) }

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}