tenseleyflow/shithub / d8fc620

Browse files

S30: orgs orchestrator — members, invitations, principals resolver

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
d8fc620449983dd37e49eeca481a7ce33a6c35fd
Parents
621070d
Tree
e636923

3 changed files

StatusFile+-
A internal/orgs/invitations.go 306 0
A internal/orgs/members.go 138 0
A internal/orgs/principals.go 51 0
internal/orgs/invitations.goadded
@@ -0,0 +1,306 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"strings"
10
+	"time"
11
+
12
+	"github.com/jackc/pgx/v5"
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
16
+	"github.com/tenseleyFlow/shithub/internal/auth/token"
17
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
+)
20
+
21
+// InviteParams describes a new invitation. Exactly one of
22
+// TargetUsername / TargetEmail must be non-empty; the resolver below
23
+// converts a username to a user_id via the users table.
24
+type InviteParams struct {
25
+	OrgID           int64
26
+	InvitedByUserID int64
27
+	TargetUsername  string
28
+	TargetEmail     string
29
+	Role            string // "owner" | "member"
30
+}
31
+
32
+// InviteResult bundles the freshly-created invitation row + the
33
+// plaintext token. Callers typically render the token into a URL and
34
+// drop it into an email; the token IS the credential — never persist
35
+// it after the email send.
36
+type InviteResult struct {
37
+	Invitation orgsdb.OrgInvitation
38
+	Token      string
39
+}
40
+
41
+// Invite creates a pending invitation. Resolves a username to a
42
+// user_id when provided; otherwise treats the input as an email
43
+// address (recipients without an account claim it on signup).
44
+func Invite(ctx context.Context, deps Deps, p InviteParams) (InviteResult, error) {
45
+	if p.OrgID == 0 || p.InvitedByUserID == 0 {
46
+		return InviteResult{}, errors.New("orgs: OrgID + InvitedByUserID required")
47
+	}
48
+	if (p.TargetUsername == "" && p.TargetEmail == "") ||
49
+		(p.TargetUsername != "" && p.TargetEmail != "") {
50
+		return InviteResult{}, ErrInvalidInvitationKind
51
+	}
52
+	role, err := parseRole(p.Role)
53
+	if err != nil {
54
+		return InviteResult{}, err
55
+	}
56
+
57
+	q := orgsdb.New()
58
+
59
+	var (
60
+		targetUserID pgtype.Int8
61
+		targetEmail  pgtype.Text
62
+	)
63
+	if p.TargetUsername != "" {
64
+		uname := strings.ToLower(strings.TrimSpace(p.TargetUsername))
65
+		u, err := usersdb.New().GetUserByUsername(ctx, deps.Pool, uname)
66
+		if err != nil {
67
+			if errors.Is(err, pgx.ErrNoRows) {
68
+				return InviteResult{}, ErrUserNotFound
69
+			}
70
+			return InviteResult{}, err
71
+		}
72
+		// Already a member? short-circuit.
73
+		if _, err := q.GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
74
+			OrgID: p.OrgID, UserID: u.ID,
75
+		}); err == nil {
76
+			return InviteResult{}, ErrAlreadyMember
77
+		}
78
+		targetUserID = pgtype.Int8{Int64: u.ID, Valid: true}
79
+	} else {
80
+		email := strings.ToLower(strings.TrimSpace(p.TargetEmail))
81
+		if email == "" {
82
+			return InviteResult{}, ErrInvalidInvitationKind
83
+		}
84
+		targetEmail = pgtype.Text{String: email, Valid: true}
85
+	}
86
+
87
+	// Idempotency: if a pending invite for this target already exists,
88
+	// surface it rather than minting a fresh one (avoids token churn
89
+	// + accidental spam from re-clicked Invite buttons).
90
+	if _, err := q.GetExistingPendingInvitation(ctx, deps.Pool, orgsdb.GetExistingPendingInvitationParams{
91
+		OrgID:        p.OrgID,
92
+		TargetUserID: targetUserID,
93
+		TargetEmail:  emailToCitext(targetEmail),
94
+	}); err == nil {
95
+		return InviteResult{}, ErrInvitationDuplicate
96
+	} else if !errors.Is(err, pgx.ErrNoRows) {
97
+		return InviteResult{}, err
98
+	}
99
+
100
+	tokEnc, tokHash, err := token.New()
101
+	if err != nil {
102
+		return InviteResult{}, fmt.Errorf("invite token: %w", err)
103
+	}
104
+	row, err := q.CreateOrgInvitation(ctx, deps.Pool, orgsdb.CreateOrgInvitationParams{
105
+		OrgID:            p.OrgID,
106
+		InvitedByUserID:  pgtype.Int8{Int64: p.InvitedByUserID, Valid: true},
107
+		TargetUserID:     targetUserID,
108
+		TargetEmail:      emailToCitext(targetEmail),
109
+		Role:             role,
110
+		TokenHash:        tokHash,
111
+		ExpiresAt:        pgtype.Timestamptz{Time: time.Now().Add(7 * 24 * time.Hour), Valid: true},
112
+	})
113
+	if err != nil {
114
+		return InviteResult{}, fmt.Errorf("create invitation: %w", err)
115
+	}
116
+
117
+	// Best-effort email — failures don't break the invitation row.
118
+	go h.tryEmailInvite(deps, row, tokEnc) //nolint:gocritic // closure is fine
119
+
120
+	return InviteResult{Invitation: row, Token: tokEnc}, nil
121
+}
122
+
123
+// h is a tiny package-private helper struct so the closure in Invite
124
+// can pin the email-side code without dragging extra fields into
125
+// every call site. Keeps Invite signature focused.
126
+var h struct {
127
+	tryEmailInvite func(deps Deps, row orgsdb.OrgInvitation, tokEnc string)
128
+}
129
+
130
+func init() {
131
+	h.tryEmailInvite = func(deps Deps, row orgsdb.OrgInvitation, tokEnc string) {
132
+		if deps.EmailSender == nil {
133
+			return
134
+		}
135
+		var to string
136
+		if row.TargetEmail.Valid {
137
+			to = string(row.TargetEmail.String)
138
+		} else if row.TargetUserID.Valid {
139
+			u, err := usersdb.New().GetUserByID(context.Background(), deps.Pool, row.TargetUserID.Int64)
140
+			if err != nil || !u.PrimaryEmailID.Valid {
141
+				return
142
+			}
143
+			em, err := usersdb.New().GetUserEmailByID(context.Background(), deps.Pool, u.PrimaryEmailID.Int64)
144
+			if err != nil || !em.Verified {
145
+				return
146
+			}
147
+			to = string(em.Email)
148
+		}
149
+		if to == "" {
150
+			return
151
+		}
152
+		url := strings.TrimRight(deps.BaseURL, "/") + "/invitations/" + tokEnc
153
+		text := "You've been invited to join an organization on " + deps.SiteName + ".\n\n" +
154
+			"Accept or decline: " + url + "\n\n" +
155
+			"This link expires in 7 days.\n"
156
+		_ = deps.EmailSender.Send(context.Background(), email.Message{
157
+			From:    deps.EmailFrom,
158
+			To:      to,
159
+			Subject: "[" + deps.SiteName + "] You've been invited to an organization",
160
+			Text:    text,
161
+			HTML:    "<p>You've been invited to join an organization on " + deps.SiteName + ".</p><p><a href=\"" + url + "\">Accept or decline</a></p><p>This link expires in 7 days.</p>",
162
+		})
163
+	}
164
+}
165
+
166
+// AcceptInvitation marks an invitation accepted and adds the target
167
+// as a member. The acceptor is the currently-logged-in user; for
168
+// email-based invites the acceptor's verified email must match the
169
+// invite's target_email.
170
+func AcceptInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation, acceptorUserID int64) error {
171
+	if err := validatePending(inv); err != nil {
172
+		return err
173
+	}
174
+	// Email-target invites: claim only when the acceptor owns the
175
+	// email (verified primary). Username-target invites: only the
176
+	// matching user can accept.
177
+	if inv.TargetUserID.Valid && inv.TargetUserID.Int64 != acceptorUserID {
178
+		return ErrUnauthorizedAcceptor
179
+	}
180
+	if inv.TargetEmail.Valid {
181
+		ok, err := userOwnsVerifiedEmail(ctx, deps, acceptorUserID, string(inv.TargetEmail.String))
182
+		if err != nil {
183
+			return err
184
+		}
185
+		if !ok {
186
+			return ErrUnauthorizedAcceptor
187
+		}
188
+	}
189
+
190
+	tx, err := deps.Pool.Begin(ctx)
191
+	if err != nil {
192
+		return err
193
+	}
194
+	committed := false
195
+	defer func() {
196
+		if !committed {
197
+			_ = tx.Rollback(ctx)
198
+		}
199
+	}()
200
+	q := orgsdb.New()
201
+	if err := q.AddOrgMember(ctx, tx, orgsdb.AddOrgMemberParams{
202
+		OrgID:           inv.OrgID,
203
+		UserID:          acceptorUserID,
204
+		Role:            inv.Role,
205
+		InvitedByUserID: inv.InvitedByUserID,
206
+	}); err != nil {
207
+		return fmt.Errorf("add member: %w", err)
208
+	}
209
+	if err := q.AcceptOrgInvitation(ctx, tx, inv.ID); err != nil {
210
+		return fmt.Errorf("mark accepted: %w", err)
211
+	}
212
+	if err := tx.Commit(ctx); err != nil {
213
+		return err
214
+	}
215
+	committed = true
216
+	return nil
217
+}
218
+
219
+// DeclineInvitation marks an invitation declined. Same target-binding
220
+// rules as AcceptInvitation apply.
221
+func DeclineInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation, declinerUserID int64) error {
222
+	if err := validatePending(inv); err != nil {
223
+		return err
224
+	}
225
+	if inv.TargetUserID.Valid && inv.TargetUserID.Int64 != declinerUserID {
226
+		return ErrUnauthorizedAcceptor
227
+	}
228
+	if inv.TargetEmail.Valid {
229
+		ok, err := userOwnsVerifiedEmail(ctx, deps, declinerUserID, string(inv.TargetEmail.String))
230
+		if err != nil {
231
+			return err
232
+		}
233
+		if !ok {
234
+			return ErrUnauthorizedAcceptor
235
+		}
236
+	}
237
+	return orgsdb.New().DeclineOrgInvitation(ctx, deps.Pool, inv.ID)
238
+}
239
+
240
+// CancelInvitation marks an invitation canceled. Caller is the org
241
+// owner / inviter; policy is checked before this is invoked.
242
+func CancelInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation) error {
243
+	if err := validatePending(inv); err != nil {
244
+		return err
245
+	}
246
+	return orgsdb.New().CancelOrgInvitation(ctx, deps.Pool, inv.ID)
247
+}
248
+
249
+// LookupInvitationByToken fetches the invitation matching the
250
+// supplied bearer token. Returns ErrInvitationNotFound when the token
251
+// doesn't match a row.
252
+func LookupInvitationByToken(ctx context.Context, deps Deps, encodedToken string) (orgsdb.OrgInvitation, error) {
253
+	hash, err := token.HashOf(encodedToken)
254
+	if err != nil {
255
+		return orgsdb.OrgInvitation{}, ErrInvitationNotFound
256
+	}
257
+	row, err := orgsdb.New().GetOrgInvitationByTokenHash(ctx, deps.Pool, hash)
258
+	if err != nil {
259
+		if errors.Is(err, pgx.ErrNoRows) {
260
+			return orgsdb.OrgInvitation{}, ErrInvitationNotFound
261
+		}
262
+		return orgsdb.OrgInvitation{}, err
263
+	}
264
+	return row, nil
265
+}
266
+
267
+// ─── helpers ───────────────────────────────────────────────────────
268
+
269
+// ErrUnauthorizedAcceptor is returned when an acceptor doesn't own
270
+// the email the invite was issued to (or doesn't match the invited
271
+// user).
272
+var ErrUnauthorizedAcceptor = errors.New("orgs: invitation does not match this user")
273
+
274
+func validatePending(inv orgsdb.OrgInvitation) error {
275
+	if inv.AcceptedAt.Valid || inv.DeclinedAt.Valid || inv.CanceledAt.Valid {
276
+		return ErrInvitationConsumed
277
+	}
278
+	if !inv.ExpiresAt.Valid || inv.ExpiresAt.Time.Before(time.Now()) {
279
+		return ErrInvitationExpired
280
+	}
281
+	return nil
282
+}
283
+
284
+func userOwnsVerifiedEmail(ctx context.Context, deps Deps, userID int64, email string) (bool, error) {
285
+	rows, err := usersdb.New().ListUserEmailsForUser(ctx, deps.Pool, userID)
286
+	if err != nil {
287
+		return false, err
288
+	}
289
+	low := strings.ToLower(email)
290
+	for _, e := range rows {
291
+		if strings.ToLower(string(e.Email)) == low && e.Verified {
292
+			return true, nil
293
+		}
294
+	}
295
+	return false, nil
296
+}
297
+
298
+// emailToCitext is a tiny shim because the sqlc-generated email
299
+// column type for `target_email` is a non-pointer pgtype.Text-flavored
300
+// citext. Pass-through when valid; zero value otherwise.
301
+func emailToCitext(p pgtype.Text) pgtype.Text {
302
+	if !p.Valid {
303
+		return pgtype.Text{Valid: false}
304
+	}
305
+	return p
306
+}
internal/orgs/members.goadded
@@ -0,0 +1,138 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+
10
+	"github.com/jackc/pgx/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
14
+)
15
+
16
+// AddMember inserts an (org, user) pair with the supplied role.
17
+// Idempotent on the pair: a re-add for an existing member is a no-op
18
+// at the DB layer (matches the sqlc query's ON CONFLICT DO NOTHING).
19
+// Caller is responsible for the policy check that the actor is allowed
20
+// to manage members.
21
+func AddMember(ctx context.Context, deps Deps, orgID, userID, invitedByUserID int64, role string) error {
22
+	if role == "" {
23
+		role = "member"
24
+	}
25
+	r, err := parseRole(role)
26
+	if err != nil {
27
+		return err
28
+	}
29
+	return orgsdb.New().AddOrgMember(ctx, deps.Pool, orgsdb.AddOrgMemberParams{
30
+		OrgID:           orgID,
31
+		UserID:          userID,
32
+		Role:            r,
33
+		InvitedByUserID: pgtype.Int8{Int64: invitedByUserID, Valid: invitedByUserID != 0},
34
+	})
35
+}
36
+
37
+// ChangeRole updates a member's role with last-owner protection: the
38
+// only owner cannot be demoted (refuse with ErrLastOwner). Caller has
39
+// already verified the actor's policy.
40
+func ChangeRole(ctx context.Context, deps Deps, orgID, userID int64, role string) error {
41
+	r, err := parseRole(role)
42
+	if err != nil {
43
+		return err
44
+	}
45
+	q := orgsdb.New()
46
+	current, err := q.GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
47
+		OrgID: orgID, UserID: userID,
48
+	})
49
+	if err != nil {
50
+		if errors.Is(err, pgx.ErrNoRows) {
51
+			return ErrNotAMember
52
+		}
53
+		return err
54
+	}
55
+	if current.Role == orgsdb.OrgRoleOwner && r != orgsdb.OrgRoleOwner {
56
+		// Demoting an owner: refuse if they're the last one.
57
+		count, err := q.CountOrgOwners(ctx, deps.Pool, orgID)
58
+		if err != nil {
59
+			return err
60
+		}
61
+		if count <= 1 {
62
+			return ErrLastOwner
63
+		}
64
+	}
65
+	return q.ChangeOrgMemberRole(ctx, deps.Pool, orgsdb.ChangeOrgMemberRoleParams{
66
+		OrgID: orgID, UserID: userID, Role: r,
67
+	})
68
+}
69
+
70
+// RemoveMember deletes the (org, user) row. Last-owner protection
71
+// applies — we refuse to drop the only owner. Removing oneself is
72
+// fine when there are ≥2 owners.
73
+func RemoveMember(ctx context.Context, deps Deps, orgID, userID int64) error {
74
+	q := orgsdb.New()
75
+	current, err := q.GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
76
+		OrgID: orgID, UserID: userID,
77
+	})
78
+	if err != nil {
79
+		if errors.Is(err, pgx.ErrNoRows) {
80
+			return ErrNotAMember
81
+		}
82
+		return err
83
+	}
84
+	if current.Role == orgsdb.OrgRoleOwner {
85
+		count, err := q.CountOrgOwners(ctx, deps.Pool, orgID)
86
+		if err != nil {
87
+			return err
88
+		}
89
+		if count <= 1 {
90
+			return ErrLastOwner
91
+		}
92
+	}
93
+	return q.RemoveOrgMember(ctx, deps.Pool, orgsdb.RemoveOrgMemberParams{
94
+		OrgID: orgID, UserID: userID,
95
+	})
96
+}
97
+
98
+// IsMember reports whether the user is a member of the org. Used by
99
+// policy + the org-owner repo-create gate.
100
+func IsMember(ctx context.Context, deps Deps, orgID, userID int64) (bool, error) {
101
+	_, err := orgsdb.New().GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
102
+		OrgID: orgID, UserID: userID,
103
+	})
104
+	if err != nil {
105
+		if errors.Is(err, pgx.ErrNoRows) {
106
+			return false, nil
107
+		}
108
+		return false, err
109
+	}
110
+	return true, nil
111
+}
112
+
113
+// IsOwner reports whether the user is an owner of the org.
114
+func IsOwner(ctx context.Context, deps Deps, orgID, userID int64) (bool, error) {
115
+	row, err := orgsdb.New().GetOrgMember(ctx, deps.Pool, orgsdb.GetOrgMemberParams{
116
+		OrgID: orgID, UserID: userID,
117
+	})
118
+	if err != nil {
119
+		if errors.Is(err, pgx.ErrNoRows) {
120
+			return false, nil
121
+		}
122
+		return false, err
123
+	}
124
+	return row.Role == orgsdb.OrgRoleOwner, nil
125
+}
126
+
127
+// parseRole returns the typed enum value, or an error for unknown
128
+// strings (defends against a hand-crafted POST body).
129
+func parseRole(s string) (orgsdb.OrgRole, error) {
130
+	switch s {
131
+	case "owner":
132
+		return orgsdb.OrgRoleOwner, nil
133
+	case "member":
134
+		return orgsdb.OrgRoleMember, nil
135
+	default:
136
+		return "", fmt.Errorf("orgs: invalid role %q", s)
137
+	}
138
+}
internal/orgs/principals.goadded
@@ -0,0 +1,51 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+
9
+	"github.com/jackc/pgx/v5"
10
+	"github.com/jackc/pgx/v5/pgxpool"
11
+
12
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
13
+)
14
+
15
+// PrincipalKind discriminates the resolution result.
16
+type PrincipalKind string
17
+
18
+const (
19
+	PrincipalUser PrincipalKind = "user"
20
+	PrincipalOrg  PrincipalKind = "org"
21
+)
22
+
23
+// Principal is the resolution result. ID is the user_id when Kind is
24
+// "user", org_id when "org".
25
+type Principal struct {
26
+	Slug string
27
+	Kind PrincipalKind
28
+	ID   int64
29
+}
30
+
31
+// ErrNoPrincipal is returned when /{slug} doesn't match a known
32
+// principal. Web handlers translate this to 404.
33
+var ErrNoPrincipal = errors.New("orgs: no principal for slug")
34
+
35
+// Resolve answers the central /{slug} routing question with one
36
+// indexed lookup. Returns ErrNoPrincipal for unknown slugs (handler
37
+// 404s); other errors are surfaced as-is.
38
+func Resolve(ctx context.Context, pool *pgxpool.Pool, slug string) (Principal, error) {
39
+	row, err := orgsdb.New().ResolvePrincipal(ctx, pool, slug)
40
+	if err != nil {
41
+		if errors.Is(err, pgx.ErrNoRows) {
42
+			return Principal{}, ErrNoPrincipal
43
+		}
44
+		return Principal{}, err
45
+	}
46
+	return Principal{
47
+		Slug: string(row.Slug),
48
+		Kind: PrincipalKind(row.Kind),
49
+		ID:   row.ID,
50
+	}, nil
51
+}