Go · 12016 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 default:
125 return nil, fmt.Errorf("admin: unknown email_backend %q", cfg.Auth.EmailBackend)
126 }
127 }
128
129 var adminClear2FACmd = &cobra.Command{
130 Use: "clear-2fa <username>",
131 Short: "Clear 2FA enrollment from a user account (support escape hatch)",
132 Long: `Removes the user's TOTP enrollment and recovery codes, writes an
133 audit-log row, and emails the user a notification. Use only when the user
134 has lost both their authenticator device and their recovery codes —
135 typically after manual identity verification through a support channel.`,
136 Args: cobra.ExactArgs(1),
137 RunE: func(cmd *cobra.Command, args []string) error {
138 username := args[0]
139 cfg, err := config.Load(nil)
140 if err != nil {
141 return err
142 }
143 if cfg.DB.URL == "" {
144 return errors.New("admin clear-2fa: DB not configured (set SHITHUB_DATABASE_URL)")
145 }
146 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
147 defer cancel()
148
149 pool, err := db.Open(ctx, db.Config{
150 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
151 ConnectTimeout: cfg.DB.ConnectTimeout,
152 })
153 if err != nil {
154 return fmt.Errorf("db open: %w", err)
155 }
156 defer pool.Close()
157
158 q := usersdb.New()
159 user, err := q.GetUserByUsername(ctx, pool, username)
160 if err != nil {
161 return fmt.Errorf("user %q not found", username)
162 }
163
164 tx, err := pool.Begin(ctx)
165 if err != nil {
166 return fmt.Errorf("begin: %w", err)
167 }
168 defer func() { _ = tx.Rollback(ctx) }()
169
170 if err := q.DeleteUserTOTP(ctx, tx, user.ID); err != nil {
171 return fmt.Errorf("delete totp: %w", err)
172 }
173 if err := q.DeleteUserRecoveryCodes(ctx, tx, user.ID); err != nil {
174 return fmt.Errorf("delete recovery: %w", err)
175 }
176 recorder := audit.NewRecorder()
177 if err := recorder.Record(ctx, tx, 0,
178 audit.ActionAdminCleared2FA, audit.TargetUser, user.ID,
179 map[string]any{"admin": "cli"}); err != nil {
180 return fmt.Errorf("audit: %w", err)
181 }
182 if err := tx.Commit(ctx); err != nil {
183 return fmt.Errorf("commit: %w", err)
184 }
185
186 // Best-effort notification email.
187 if user.PrimaryEmailID.Valid {
188 em, err := q.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
189 if err == nil {
190 sender, err := pickAdminEmailSender(cfg)
191 if err == nil {
192 msg, err := email.NoticeMessage(email.Branding{
193 SiteName: cfg.Auth.SiteName,
194 BaseURL: cfg.Auth.BaseURL,
195 From: cfg.Auth.EmailFrom,
196 }, string(em.Email), user.Username, "admin_cleared_2fa")
197 if err == nil {
198 if err := sender.Send(ctx, msg); err != nil {
199 _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warn: notification email failed: %v\n", err)
200 }
201 }
202 }
203 }
204 }
205
206 _, _ = fmt.Fprintf(cmd.OutOrStdout(),
207 "clear-2fa: 2FA + recovery codes cleared for %s; audit row written\n", user.Username)
208 return nil
209 },
210 }
211
212 // adminBootstrapAdminCmd is the chicken-and-egg solver for first
213 // install: there's no /admin/users/{id} toggle reachable until at
214 // least one user already has is_site_admin=true. Operators run this
215 // once on a fresh deploy. Subsequent admin grants happen through the
216 // /admin UI under an existing admin's session.
217 var adminBootstrapAdminCmd = &cobra.Command{
218 Use: "bootstrap-admin <username>",
219 Short: "Set users.is_site_admin=true on a single user (first-install bootstrap)",
220 Long: `Flips the site-admin flag on a user account so the /admin surface
221 is reachable. Designed for the first-install bootstrap: once any admin
222 exists, future grants happen in /admin/users/{id}.
223
224 Writes an audit row attributed to actor_id=0 (CLI) so the bootstrap
225 remains traceable.`,
226 Args: cobra.ExactArgs(1),
227 RunE: func(cmd *cobra.Command, args []string) error {
228 username := args[0]
229 cfg, err := config.Load(nil)
230 if err != nil {
231 return err
232 }
233 if cfg.DB.URL == "" {
234 return errors.New("admin bootstrap-admin: DB not configured (set SHITHUB_DATABASE_URL)")
235 }
236 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
237 defer cancel()
238 pool, err := db.Open(ctx, db.Config{
239 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
240 ConnectTimeout: cfg.DB.ConnectTimeout,
241 })
242 if err != nil {
243 return fmt.Errorf("db open: %w", err)
244 }
245 defer pool.Close()
246 uq := usersdb.New()
247 user, err := uq.GetUserByUsername(ctx, pool, username)
248 if err != nil {
249 return fmt.Errorf("user %q not found", username)
250 }
251 aq := admindb.New()
252 if err := aq.SetUserSiteAdmin(ctx, pool, admindb.SetUserSiteAdminParams{
253 ID: user.ID, IsSiteAdmin: true,
254 }); err != nil {
255 return fmt.Errorf("set site admin: %w", err)
256 }
257 recorder := audit.NewRecorder()
258 _ = recorder.Record(ctx, pool, 0,
259 audit.ActionAdminSiteAdminGranted, audit.TargetUser, user.ID,
260 map[string]any{"via": "cli", "bootstrap": true})
261 _, _ = fmt.Fprintf(cmd.OutOrStdout(),
262 "bootstrap-admin: %s now has is_site_admin=true\n", user.Username)
263 return nil
264 },
265 }
266
267 // adminRunJobCmd enqueues an arbitrary worker job. Break-glass for
268 // operators when the normal trigger path is broken or the job needs a
269 // one-off invocation.
270 var adminRunJobCmd = &cobra.Command{
271 Use: "run-job <kind> [payload-json]",
272 Short: "Enqueue a worker job by kind + JSON payload",
273 Long: `Inserts a row into the jobs queue with the given kind and payload.
274 Payload defaults to {} when omitted. The worker pool picks the job up
275 on its next claim cycle (within IdlePoll seconds).`,
276 Args: cobra.RangeArgs(1, 2),
277 RunE: func(cmd *cobra.Command, args []string) error {
278 kind := args[0]
279 payloadJSON := []byte("{}")
280 if len(args) == 2 {
281 payloadJSON = []byte(args[1])
282 }
283 var payload map[string]any
284 if err := json.Unmarshal(payloadJSON, &payload); err != nil {
285 return fmt.Errorf("payload not valid JSON: %w", err)
286 }
287 cfg, err := config.Load(nil)
288 if err != nil {
289 return err
290 }
291 if cfg.DB.URL == "" {
292 return errors.New("admin run-job: DB not configured")
293 }
294 ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
295 defer cancel()
296 pool, err := db.Open(ctx, db.Config{
297 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
298 ConnectTimeout: cfg.DB.ConnectTimeout,
299 })
300 if err != nil {
301 return fmt.Errorf("db open: %w", err)
302 }
303 defer pool.Close()
304 id, err := worker.Enqueue(ctx, pool, worker.Kind(kind), payload, worker.EnqueueOptions{})
305 if err != nil {
306 return fmt.Errorf("enqueue: %w", err)
307 }
308 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "run-job: enqueued #%d (kind=%s)\n", id, kind)
309 return nil
310 },
311 }
312
313 // adminRecomputeCmd triggers a counter reconciler. Useful when a
314 // star_count / fork_count drifts from the source-of-truth row count.
315 // Currently supports `star_count` and `fork_count`; new metrics get
316 // new branches here.
317 var adminRecomputeCmd = &cobra.Command{
318 Use: "recompute <metric>",
319 Short: "Recompute a denormalized counter (star_count, fork_count)",
320 Args: cobra.ExactArgs(1),
321 RunE: func(cmd *cobra.Command, args []string) error {
322 metric := args[0]
323 cfg, err := config.Load(nil)
324 if err != nil {
325 return err
326 }
327 if cfg.DB.URL == "" {
328 return errors.New("admin recompute: DB not configured")
329 }
330 ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
331 defer cancel()
332 pool, err := db.Open(ctx, db.Config{
333 URL: cfg.DB.URL, MaxConns: 2, MinConns: 0,
334 ConnectTimeout: cfg.DB.ConnectTimeout,
335 })
336 if err != nil {
337 return fmt.Errorf("db open: %w", err)
338 }
339 defer pool.Close()
340 var sql string
341 switch metric {
342 case "star_count":
343 sql = `UPDATE repos r
344 SET star_count = COALESCE(s.n, 0)
345 FROM (SELECT repo_id, COUNT(*)::bigint AS n FROM stars GROUP BY repo_id) s
346 WHERE r.id = s.repo_id`
347 case "fork_count":
348 sql = `UPDATE repos r
349 SET fork_count = COALESCE(f.n, 0)
350 FROM (SELECT fork_of_repo_id AS rid, COUNT(*)::bigint AS n
351 FROM repos WHERE fork_of_repo_id IS NOT NULL GROUP BY fork_of_repo_id) f
352 WHERE r.id = f.rid`
353 default:
354 return fmt.Errorf("unknown metric %q (want star_count | fork_count)", metric)
355 }
356 tag, err := pool.Exec(ctx, sql)
357 if err != nil {
358 return fmt.Errorf("recompute: %w", err)
359 }
360 _, _ = fmt.Fprintf(cmd.OutOrStdout(),
361 "recompute: %s — %d rows updated\n", metric, tag.RowsAffected())
362 return nil
363 },
364 }
365
366 func init() {
367 adminCmd.AddCommand(adminResetPasswordCmd)
368 adminCmd.AddCommand(adminClear2FACmd)
369 adminCmd.AddCommand(adminBootstrapAdminCmd)
370 adminCmd.AddCommand(adminRunJobCmd)
371 adminCmd.AddCommand(adminRecomputeCmd)
372 }
373