ActiveWeeks computes per-user distinct active weeks (reads login_events UNION acts summary_actions) for the gate (VISION/ADR-016: usage in >=2 distinct weeks). Because the user-owned tables are FORCE RLS under a non-superuser owner, a single cross-user query is deny-all; instead it enumerates users from the un-RLS'd identity map and counts each inside withUser — no privilege escalation, no policy change. `tapir report` prints the per-user table and the pass/fail verdict (needs only TAPIR_DB_DSN). Pure formatter + store query are unit-tested, including the cross-table shared-week dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
3.8 KiB
Go
112 lines
3.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// UserActiveWeeks is one row of the Stage-0 gate report: how many DISTINCT
|
|
// calendar weeks a user was active in, counting reads (login_events) AND acts
|
|
// (summary_actions) together. The gate (VISION/ADR-016) passes when any user
|
|
// reaches ActiveWeeks >= 2.
|
|
type UserActiveWeeks struct {
|
|
UserID string
|
|
DisplayName string
|
|
ActiveWeeks int
|
|
}
|
|
|
|
// ActiveWeeks computes per-user distinct-active-weeks for the gate report, most
|
|
// active first.
|
|
//
|
|
// Why per-user iteration rather than one cross-user GROUP BY: the user-owned
|
|
// tables are FORCE RLS (migration 003/010) and the production role is a non-
|
|
// superuser owner, so a single un-scoped query sees nothing (deny-all). Instead we
|
|
// enumerate users from the deliberately un-RLS'd identity map (user_identities,
|
|
// migration 004) and count each user's weeks inside withUser, where the GUC scopes
|
|
// login_events + summary_actions to that user. No privilege escalation, no policy
|
|
// change — the same isolation seam every other read flows through.
|
|
//
|
|
// Scope note: the enumeration covers users with a Dex identity (the web users the
|
|
// gate is about). A CLI-only user created by the store sink without an identity
|
|
// row would not appear — out of scope for this gate.
|
|
func (s *Store) ActiveWeeks(ctx context.Context) ([]UserActiveWeeks, error) {
|
|
userIDs, err := s.identityUserIDs(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]UserActiveWeeks, 0, len(userIDs))
|
|
for _, uid := range userIDs {
|
|
row, err := s.activeWeeksFor(ctx, uid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
|
|
// Most active first; user_id as a stable tie-break for deterministic output.
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].ActiveWeeks != out[j].ActiveWeeks {
|
|
return out[i].ActiveWeeks > out[j].ActiveWeeks
|
|
}
|
|
return out[i].UserID < out[j].UserID
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
// identityUserIDs lists every tapir user_id from the un-RLS'd identity map. It
|
|
// runs directly on the pool (no withUser): user_identities carries no user data
|
|
// and is intentionally not RLS-enabled, so it is the one table that can be read
|
|
// pre-scope to discover who exists.
|
|
func (s *Store) identityUserIDs(ctx context.Context) ([]string, error) {
|
|
rows, err := s.pool.Query(ctx, `SELECT user_id FROM user_identities ORDER BY user_id`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: list identity users: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, fmt.Errorf("store: scan identity user: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("store: iterate identity users: %w", err)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// activeWeeksFor counts one user's distinct active weeks (reads UNION acts) and
|
|
// reads their display name, RLS-scoped via withUser. The UNION dedups a week that
|
|
// has both a login and an action so it counts once.
|
|
func (s *Store) activeWeeksFor(ctx context.Context, userID string) (UserActiveWeeks, error) {
|
|
res := UserActiveWeeks{UserID: userID}
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
if err := tx.QueryRow(ctx,
|
|
`WITH weeks AS (
|
|
SELECT date_trunc('week', seen_at) AS wk
|
|
FROM login_events WHERE user_id = $1
|
|
UNION
|
|
SELECT date_trunc('week', acted_at)
|
|
FROM summary_actions WHERE user_id = $1
|
|
)
|
|
SELECT count(DISTINCT wk) FROM weeks`, userID).Scan(&res.ActiveWeeks); err != nil {
|
|
return fmt.Errorf("store: count active weeks: %w", err)
|
|
}
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT COALESCE(display_name, '') FROM users WHERE id = $1`, userID).Scan(&res.DisplayName); err != nil {
|
|
return fmt.Errorf("store: read display name: %w", err)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return UserActiveWeeks{}, err
|
|
}
|
|
return res, nil
|
|
}
|