275 lines
9.0 KiB
Go
275 lines
9.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
|
"github.com/krow/krow-backend/go-api/internal/repo"
|
|
)
|
|
|
|
// Periods a caller may ask for. A closed set, because a period is a window this
|
|
// code computes — never a date a caller supplies, and never a string
|
|
// interpolated anywhere near SQL.
|
|
var periodWindows = map[string]time.Duration{
|
|
"today": 24 * time.Hour,
|
|
"last-7-days": 7 * 24 * time.Hour,
|
|
"last-30-days": 30 * 24 * time.Hour,
|
|
"this-month": 0, // computed from the 1st — see windowFor
|
|
"previous-month": 0,
|
|
}
|
|
|
|
type activityInput struct {
|
|
Period string `json:"period"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
// ActivityBreakdown counts the audit log by kind of event and by account.
|
|
//
|
|
// The Go port of `activity.breakdown` from the frontend's dataResolver, and the
|
|
// difference is the entire point of Phase 2. The JavaScript took records that
|
|
// had already been fetched into the browser:
|
|
//
|
|
// 'activity.breakdown': ({ activity = [] }, section, now) => …
|
|
//
|
|
// It had no principal, so it could not have authorized anything even if it had
|
|
// wanted to — the filtering had already happened, or hadn't, somewhere else.
|
|
// This version takes the caller, resolves the policy, and pushes the resulting
|
|
// predicate into the query. A talent caller's own scope is a WHERE clause, so
|
|
// the counts, the shares and the "accounts active" figure are all computed over
|
|
// exactly the rows that caller could have read directly. I1 and I2, in one
|
|
// query.
|
|
func ActivityBreakdown(db repo.Querier) Tool {
|
|
return Tool{
|
|
Name: "activity_breakdown",
|
|
Description: "Count workspace activity by kind of event and by account, " +
|
|
"optionally within a period. Returns totals, the number of distinct event " +
|
|
"kinds, how many accounts were active, and a row per event kind with its " +
|
|
"count and share of the total. Use this for questions about what has " +
|
|
"happened, who did it, and in what proportion.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"period": map[string]any{
|
|
"type": "string",
|
|
"enum": []string{"today", "last-7-days", "last-30-days", "this-month", "previous-month"},
|
|
"description": "The window to count within. Omit to count the whole log. " +
|
|
"Windows are computed from the current date; do not pass a date.",
|
|
},
|
|
"limit": map[string]any{
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": 100,
|
|
"description": "How many event kinds to return, most frequent first. Defaults to 20.",
|
|
},
|
|
},
|
|
"additionalProperties": false,
|
|
},
|
|
Effect: EffectRead,
|
|
MaxResultBytes: DefaultMaxResultBytes,
|
|
Handler: activityBreakdownHandler(db),
|
|
}
|
|
}
|
|
|
|
func activityBreakdownHandler(db repo.Querier) Handler {
|
|
return func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
|
// Authorization, first line of the body. §8.
|
|
role, ok := domain.ParseRole(tc.Principal.Role)
|
|
if !ok || !activityPolicy().Allows(domain.OpList, role) {
|
|
return Denied()
|
|
}
|
|
if tc.OrgID() == "" {
|
|
// No tenant means no query. I5 — there is no "all organizations"
|
|
// read, and a missing org is a bug upstream, not a wildcard.
|
|
return Denied()
|
|
}
|
|
|
|
var in activityInput
|
|
if len(inputs) > 0 {
|
|
if err := json.Unmarshal(inputs, &in); err != nil {
|
|
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
|
}
|
|
}
|
|
if in.Limit <= 0 {
|
|
in.Limit = 20
|
|
}
|
|
if in.Limit > 100 {
|
|
in.Limit = 100
|
|
}
|
|
|
|
from, to, err := windowFor(in.Period, time.Now())
|
|
if err != nil {
|
|
return Failf(CodeInvalidInput, "%s", err.Error())
|
|
}
|
|
|
|
// The predicate, built from the policy rather than written by hand.
|
|
// Every value is a bind parameter; no identifier comes from input.
|
|
where := []string{"org_id = $1::uuid"}
|
|
args := []any{tc.OrgID()}
|
|
|
|
if scope := activityPolicy().ScopeFor(role); scope.Kind == domain.ScopeEmail {
|
|
// A talent caller sees their own entries. Pushed into the query,
|
|
// so the totals and shares below are computed over their rows and
|
|
// nobody else's — post-filtering here would leak the organization's
|
|
// volume through every percentage.
|
|
args = append(args, tc.Principal.Email)
|
|
where = append(where, fmt.Sprintf("%s = $%d", scope.Column, len(args)))
|
|
}
|
|
|
|
if !from.IsZero() {
|
|
args = append(args, from)
|
|
where = append(where, fmt.Sprintf("created_date >= $%d", len(args)))
|
|
args = append(args, to)
|
|
where = append(where, fmt.Sprintf("created_date < $%d", len(args)))
|
|
}
|
|
|
|
query := `
|
|
SELECT event_type, count(*) AS n
|
|
FROM user_activity
|
|
WHERE ` + strings.Join(where, " AND ") + `
|
|
GROUP BY event_type
|
|
ORDER BY n DESC, event_type ASC`
|
|
|
|
rows, err := db.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return Failf(CodeFailed, "the activity log could not be read")
|
|
}
|
|
defer rows.Close()
|
|
|
|
type kind struct {
|
|
Event string `json:"event"`
|
|
Count int64 `json:"count"`
|
|
Share int `json:"sharePercent"`
|
|
}
|
|
|
|
var (
|
|
kinds []kind
|
|
total int64
|
|
)
|
|
for rows.Next() {
|
|
var eventType string
|
|
var n int64
|
|
if err := rows.Scan(&eventType, &n); err != nil {
|
|
return Failf(CodeFailed, "the activity log could not be read")
|
|
}
|
|
total += n
|
|
// The stored name is a machine key. Rendered as words so the model
|
|
// is not left translating `hire_candidate` and guessing.
|
|
kinds = append(kinds, kind{Event: strings.ReplaceAll(eventType, "_", " "), Count: n})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return Failf(CodeFailed, "the activity log could not be read")
|
|
}
|
|
|
|
for i := range kinds {
|
|
if total > 0 {
|
|
kinds[i].Share = int(float64(kinds[i].Count)/float64(total)*100 + 0.5)
|
|
}
|
|
}
|
|
|
|
// Counted before the limit is applied, so "how many kinds are there"
|
|
// stays true even when the list shown is shorter.
|
|
distinctKinds := len(kinds)
|
|
omitted := 0
|
|
if len(kinds) > in.Limit {
|
|
omitted = len(kinds) - in.Limit
|
|
kinds = kinds[:in.Limit]
|
|
}
|
|
|
|
accounts, err := countActiveAccounts(ctx, db, where, args)
|
|
if err != nil {
|
|
return Failf(CodeFailed, "the activity log could not be read")
|
|
}
|
|
|
|
data := map[string]any{
|
|
"totalEvents": total,
|
|
"distinctKinds": distinctKinds,
|
|
"activeAccounts": accounts,
|
|
"kinds": kinds,
|
|
"period": periodOrAll(in.Period),
|
|
}
|
|
// Never a silent drop: a model handed a shortened list with no marker
|
|
// reasons about it as if it were the whole.
|
|
if omitted > 0 {
|
|
data["omittedKinds"] = omitted
|
|
}
|
|
if total == 0 {
|
|
data["note"] = "No activity matches that. This is a real answer, not a failure to look."
|
|
}
|
|
|
|
return OK(data)
|
|
}
|
|
}
|
|
|
|
// countActiveAccounts counts distinct actors under the same predicate.
|
|
//
|
|
// The same WHERE the breakdown used, so the two figures cannot disagree — a
|
|
// separate hand-written predicate here is exactly how "12 events across 40
|
|
// accounts" gets shipped.
|
|
func countActiveAccounts(ctx context.Context, db repo.Querier, where []string, args []any) (int64, error) {
|
|
var n int64
|
|
err := db.QueryRow(ctx,
|
|
`SELECT count(DISTINCT user_email) FROM user_activity WHERE `+strings.Join(where, " AND "),
|
|
args...,
|
|
).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
func periodOrAll(p string) string {
|
|
if p == "" {
|
|
return "all time"
|
|
}
|
|
return p
|
|
}
|
|
|
|
// windowFor resolves a period name to a half-open [from, to).
|
|
//
|
|
// Computed from the clock at read time, never stored and never supplied. A zero
|
|
// `from` means "no window" — count everything.
|
|
func windowFor(period string, now time.Time) (from, to time.Time, err error) {
|
|
if period == "" {
|
|
return time.Time{}, time.Time{}, nil
|
|
}
|
|
if _, ok := periodWindows[period]; !ok {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period)
|
|
}
|
|
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
firstOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
|
|
switch period {
|
|
case "today":
|
|
return startOfDay, startOfDay.AddDate(0, 0, 1), nil
|
|
case "last-7-days":
|
|
return startOfDay.AddDate(0, 0, -6), startOfDay.AddDate(0, 0, 1), nil
|
|
case "last-30-days":
|
|
return startOfDay.AddDate(0, 0, -29), startOfDay.AddDate(0, 0, 1), nil
|
|
case "this-month":
|
|
return firstOfMonth, firstOfMonth.AddDate(0, 1, 0), nil
|
|
case "previous-month":
|
|
return firstOfMonth.AddDate(0, -1, 0), firstOfMonth, nil
|
|
}
|
|
return time.Time{}, time.Time{}, fmt.Errorf("%q is not a period this tool knows", period)
|
|
}
|
|
|
|
// activityPolicy is the audit log's access rules.
|
|
//
|
|
// Read from the descriptor table rather than restated here. §13 lists "passing
|
|
// the tenant id as a plain function argument through five layers" as an
|
|
// anti-pattern for the same reason this matters: an authorization rule with two
|
|
// copies has two chances to drift, and the copy in the tool layer would be the
|
|
// one nobody re-reads when the policy changes.
|
|
//
|
|
// A missing descriptor yields a nil *Policy, which denies everything — the
|
|
// deny-by-default the table already promises.
|
|
func activityPolicy() *domain.Policy {
|
|
res, ok := domain.ResourceByPath["user-activity"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return res.Policy
|
|
}
|