Go · 12137 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package main
4
5 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "time"
12
13 "github.com/jackc/pgx/v5/pgtype"
14 "github.com/spf13/cobra"
15
16 admindb "github.com/tenseleyFlow/shithub/internal/admin/sqlc"
17 "github.com/tenseleyFlow/shithub/internal/auth/audit"
18 "github.com/tenseleyFlow/shithub/internal/auth/email"
19 "github.com/tenseleyFlow/shithub/internal/auth/token"
20 "github.com/tenseleyFlow/shithub/internal/infra/config"
21 "github.com/tenseleyFlow/shithub/internal/infra/db"
22 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
23 "github.com/tenseleyFlow/shithub/internal/worker"
24 )
25
26 var adminCmd = &cobra.Command{
27 Use: "admin",
28 Short: "Site-admin tooling (operator escape hatches)",
29 }
30
31 var adminResetPasswordCmd = &cobra.Command{
32 Use: "reset-password <username>",
33 Short: "Issue a password-reset link to the user's primary email",
34 Long: `Generates a fresh password-reset token (1-hour TTL) and sends it
35 to the user's primary email via the configured email backend. Useful when
36 a locked-out user can't drive the public reset flow themselves.
37
38 Exits non-zero if the user is unknown, has no primary email, or if the
39 email send fails.`,
40 Args: cobra.ExactArgs(1),
41 RunE: func(cmd *cobra.Command, args []string) error {
42 username := args[0]
43 cfg, err := config.Load(nil)
44 if err != nil {
45 return err
46 }
47 if cfg.DB.URL == "" {
48 return errors.New("admin reset-password: DB not configured (set SHITHUB_DATABASE_URL)")
49 }
50 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
51 defer cancel()
52
53 pool, err := db.Open(ctx, db.Config{
54 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
55 ConnectTimeout: cfg.DB.ConnectTimeout,
56 })
57 if err != nil {
58 return fmt.Errorf("db open: %w", err)
59 }
60 defer pool.Close()
61
62 q := usersdb.New()
63 user, err := q.GetUserByUsername(ctx, pool, username)
64 if err != nil {
65 return fmt.Errorf("user %q not found", username)
66 }
67 if !user.PrimaryEmailID.Valid {
68 return fmt.Errorf("user %q has no primary email on file", username)
69 }
70 em, err := q.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
71 if err != nil {
72 return fmt.Errorf("primary email lookup: %w", err)
73 }
74
75 tokEnc, tokHash, err := token.New()
76 if err != nil {
77 return fmt.Errorf("token: %w", err)
78 }
79 expires := pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}
80 if _, err := q.CreatePasswordReset(ctx, pool, usersdb.CreatePasswordResetParams{
81 UserID: user.ID, TokenHash: tokHash, ExpiresAt: expires,
82 }); err != nil {
83 return fmt.Errorf("create reset row: %w", err)
84 }
85
86 sender, err := pickAdminEmailSender(cfg)
87 if err != nil {
88 return err
89 }
90 msg, err := email.ResetMessage(email.Branding{
91 SiteName: cfg.Auth.SiteName,
92 BaseURL: cfg.Auth.BaseURL,
93 From: cfg.Auth.EmailFrom,
94 }, string(em.Email), tokEnc)
95 if err != nil {
96 return fmt.Errorf("build reset email: %w", err)
97 }
98 if err := sender.Send(ctx, msg); err != nil {
99 return fmt.Errorf("send reset email: %w", err)
100 }
101
102 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "reset-password: link emailed to %s (token expires %s)\n",
103 em.Email, expires.Time.Format(time.RFC3339))
104 return nil
105 },
106 }
107
108 func pickAdminEmailSender(cfg config.Config) (email.Sender, error) {
109 switch cfg.Auth.EmailBackend {
110 case "stdout":
111 return email.NewStdoutSender(os.Stdout), nil
112 case "smtp":
113 return &email.SMTPSender{
114 Addr: cfg.Auth.SMTP.Addr,
115 From: cfg.Auth.EmailFrom,
116 Username: cfg.Auth.SMTP.Username,
117 Password: cfg.Auth.SMTP.Password,
118 }, nil
119 case "postmark":
120 return &email.PostmarkSender{
121 ServerToken: cfg.Auth.Postmark.ServerToken,
122 From: cfg.Auth.EmailFrom,
123 }, nil
124 case "resend":
125 return &email.ResendSender{
126 APIKey: cfg.Auth.Resend.APIKey,
127 From: cfg.Auth.EmailFrom,
128 }, nil
129 default:
130 return nil, fmt.Errorf("admin: unknown email_backend %q", cfg.Auth.EmailBackend)
131 }
132 }
133
134 var adminClear2FACmd = &cobra.Command{
135 Use: "clear-2fa <username>",
136 Short: "Clear 2FA enrollment from a user account (support escape hatch)",
137 Long: `Removes the user's TOTP enrollment and recovery codes, writes an
138 audit-log row, and emails the user a notification. Use only when the user
139 has lost both their authenticator device and their recovery codes —
140 typically after manual identity verification through a support channel.`,
141 Args: cobra.ExactArgs(1),
142 RunE: func(cmd *cobra.Command, args []string) error {
143 username := args[0]
144 cfg, err := config.Load(nil)
145 if err != nil {
146 return err
147 }
148 if cfg.DB.URL == "" {
149 return errors.New("admin clear-2fa: DB not configured (set SHITHUB_DATABASE_URL)")
150 }
151 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
152 defer cancel()
153
154 pool, err := db.Open(ctx, db.Config{
155 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
156 ConnectTimeout: cfg.DB.ConnectTimeout,
157 })
158 if err != nil {
159 return fmt.Errorf("db open: %w", err)
160 }
161 defer pool.Close()
162
163 q := usersdb.New()
164 user, err := q.GetUserByUsername(ctx, pool, username)
165 if err != nil {
166 return fmt.Errorf("user %q not found", username)
167 }
168
169 tx, err := pool.Begin(ctx)
170 if err != nil {
171 return fmt.Errorf("begin: %w", err)
172 }
173 defer func() { _ = tx.Rollback(ctx) }()
174
175 if err := q.DeleteUserTOTP(ctx, tx, user.ID); err != nil {
176 return fmt.Errorf("delete totp: %w", err)
177 }
178 if err := q.DeleteUserRecoveryCodes(ctx, tx, user.ID); err != nil {
179 return fmt.Errorf("delete recovery: %w", err)
180 }
181 recorder := audit.NewRecorder()
182 if err := recorder.Record(ctx, tx, 0,
183 audit.ActionAdminCleared2FA, audit.TargetUser, user.ID,
184 map[string]any{"admin": "cli"}); err != nil {
185 return fmt.Errorf("audit: %w", err)
186 }
187 if err := tx.Commit(ctx); err != nil {
188 return fmt.Errorf("commit: %w", err)
189 }
190
191 // Best-effort notification email.
192 if user.PrimaryEmailID.Valid {
193 em, err := q.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
194 if err == nil {
195 sender, err := pickAdminEmailSender(cfg)
196 if err == nil {
197 msg, err := email.NoticeMessage(email.Branding{
198 SiteName: cfg.Auth.SiteName,
199 BaseURL: cfg.Auth.BaseURL,
200 From: cfg.Auth.EmailFrom,
201 }, string(em.Email), user.Username, "admin_cleared_2fa")
202 if err == nil {
203 if err := sender.Send(ctx, msg); err != nil {
204 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warn: notification email failed: %v\n", err)
205 }
206 }
207 }
208 }
209 }
210
211 _, _ = fmt.Fprintf(cmd.OutOrStdout(),
212 "clear-2fa: 2FA + recovery codes cleared for %s; audit row written\n", user.Username)
213 return nil
214 },
215 }
216
217 // adminBootstrapAdminCmd is the chicken-and-egg solver for first
218 // install: there's no /admin/users/{id} toggle reachable until at
219 // least one user already has is_site_admin=true. Operators run this
220 // once on a fresh deploy. Subsequent admin grants happen through the
221 // /admin UI under an existing admin's session.
222 var adminBootstrapAdminCmd = &cobra.Command{
223 Use: "bootstrap-admin <username>",
224 Short: "Set users.is_site_admin=true on a single user (first-install bootstrap)",
225 Long: `Flips the site-admin flag on a user account so the /admin surface
226 is reachable. Designed for the first-install bootstrap: once any admin
227 exists, future grants happen in /admin/users/{id}.
228
229 Writes an audit row attributed to actor_id=0 (CLI) so the bootstrap
230 remains traceable.`,
231 Args: cobra.ExactArgs(1),
232 RunE: func(cmd *cobra.Command, args []string) error {
233 username := args[0]
234 cfg, err := config.Load(nil)
235 if err != nil {
236 return err
237 }
238 if cfg.DB.URL == "" {
239 return errors.New("admin bootstrap-admin: DB not configured (set SHITHUB_DATABASE_URL)")
240 }
241 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
242 defer cancel()
243 pool, err := db.Open(ctx, db.Config{
244 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
245 ConnectTimeout: cfg.DB.ConnectTimeout,
246 })
247 if err != nil {
248 return fmt.Errorf("db open: %w", err)
249 }
250 defer pool.Close()
251 uq := usersdb.New()
252 user, err := uq.GetUserByUsername(ctx, pool, username)
253 if err != nil {
254 return fmt.Errorf("user %q not found", username)
255 }
256 aq := admindb.New()
257 if err := aq.SetUserSiteAdmin(ctx, pool, admindb.SetUserSiteAdminParams{
258 ID: user.ID, IsSiteAdmin: true,
259 }); err != nil {
260 return fmt.Errorf("set site admin: %w", err)
261 }
262 recorder := audit.NewRecorder()
263 _ = recorder.Record(ctx, pool, 0,
264 audit.ActionAdminSiteAdminGranted, audit.TargetUser, user.ID,
265 map[string]any{"via": "cli", "bootstrap": true})
266 _, _ = fmt.Fprintf(cmd.OutOrStdout(),
267 "bootstrap-admin: %s now has is_site_admin=true\n", user.Username)
268 return nil
269 },
270 }
271
272 // adminRunJobCmd enqueues an arbitrary worker job. Break-glass for
273 // operators when the normal trigger path is broken or the job needs a
274 // one-off invocation.
275 var adminRunJobCmd = &cobra.Command{
276 Use: "run-job <kind> [payload-json]",
277 Short: "Enqueue a worker job by kind + JSON payload",
278 Long: `Inserts a row into the jobs queue with the given kind and payload.
279 Payload defaults to {} when omitted. The worker pool picks the job up
280 on its next claim cycle (within IdlePoll seconds).`,
281 Args: cobra.RangeArgs(1, 2),
282 RunE: func(cmd *cobra.Command, args []string) error {
283 kind := args[0]
284 payloadJSON := []byte("{}")
285 if len(args) == 2 {
286 payloadJSON = []byte(args[1])
287 }
288 var payload map[string]any
289 if err := json.Unmarshal(payloadJSON, &payload); err != nil {
290 return fmt.Errorf("payload not valid JSON: %w", err)
291 }
292 cfg, err := config.Load(nil)
293 if err != nil {
294 return err
295 }
296 if cfg.DB.URL == "" {
297 return errors.New("admin run-job: DB not configured")
298 }
299 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
300 defer cancel()
301 pool, err := db.Open(ctx, db.Config{
302 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
303 ConnectTimeout: cfg.DB.ConnectTimeout,
304 })
305 if err != nil {
306 return fmt.Errorf("db open: %w", err)
307 }
308 defer pool.Close()
309 id, err := worker.Enqueue(ctx, pool, worker.Kind(kind), payload, worker.EnqueueOptions{})
310 if err != nil {
311 return fmt.Errorf("enqueue: %w", err)
312 }
313 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "run-job: enqueued #%d (kind=%s)\n", id, kind)
314 return nil
315 },
316 }
317
318 // adminRecomputeCmd triggers a counter reconciler. Useful when a
319 // star_count / fork_count drifts from the source-of-truth row count.
320 // Currently supports `star_count` and `fork_count`; new metrics get
321 // new branches here.
322 var adminRecomputeCmd = &cobra.Command{
323 Use: "recompute <metric>",
324 Short: "Recompute a denormalized counter (star_count, fork_count)",
325 Args: cobra.ExactArgs(1),
326 RunE: func(cmd *cobra.Command, args []string) error {
327 metric := args[0]
328 cfg, err := config.Load(nil)
329 if err != nil {
330 return err
331 }
332 if cfg.DB.URL == "" {
333 return errors.New("admin recompute: DB not configured")
334 }
335 ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
336 defer cancel()
337 pool, err := db.Open(ctx, db.Config{
338 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
339 ConnectTimeout: cfg.DB.ConnectTimeout,
340 })
341 if err != nil {
342 return fmt.Errorf("db open: %w", err)
343 }
344 defer pool.Close()
345 var sql string
346 switch metric {
347 case "star_count":
348 sql = `UPDATE repos r
349 SET star_count = COALESCE(s.n, 0)
350 FROM (SELECT repo_id, COUNT(*)::bigint AS n FROM stars GROUP BY repo_id) s
351 WHERE r.id = s.repo_id`
352 case "fork_count":
353 sql = `UPDATE repos r
354 SET fork_count = COALESCE(f.n, 0)
355 FROM (SELECT fork_of_repo_id AS rid, COUNT(*)::bigint AS n
356 FROM repos WHERE fork_of_repo_id IS NOT NULL GROUP BY fork_of_repo_id) f
357 WHERE r.id = f.rid`
358 default:
359 return fmt.Errorf("unknown metric %q (want star_count | fork_count)", metric)
360 }
361 tag, err := pool.Exec(ctx, sql)
362 if err != nil {
363 return fmt.Errorf("recompute: %w", err)
364 }
365 _, _ = fmt.Fprintf(cmd.OutOrStdout(),
366 "recompute: %s — %d rows updated\n", metric, tag.RowsAffected())
367 return nil
368 },
369 }
370
371 func init() {
372 adminCmd.AddCommand(adminResetPasswordCmd)
373 adminCmd.AddCommand(adminClear2FACmd)
374 adminCmd.AddCommand(adminBootstrapAdminCmd)
375 adminCmd.AddCommand(adminRunJobCmd)
376 adminCmd.AddCommand(adminRecomputeCmd)
377 }
378