Smart 360 collects sensitive workplace feedback, so the way we handle that data — and how we use AI on top of it — is a first-class concern. This document is the single source of truth for:
- Reporting a vulnerability
- Authentication & sessions
- Anonymity & privacy by design
- How Google Gemini is used
- Operational hardening checklist
If you've found something exploitable, please do not open a public GitHub issue. Use the GitHub private vulnerability reporting form for this repo — it routes the report straight to the maintainers and keeps the details private until a fix ships.
What helps triage move quickly:
- Affected commit / branch (e.g.
mainat SHA…). - Reproduction steps — a minimal
curlor screen recording is gold. - Impact you observed and what you think the worst case is.
- Login is Google OAuth 2.0, scoped to
profile+emailonly (no drive, calendar, or contacts). A login-CSRF state cookie protects the OAuth redirect. - On success the server creates a server-side session (a row in the
sessionstable) and sets an HttpOnly, Secure, SameSite=Lax cookie holding an opaque, HMAC-signed session ID. Tokens are never exposed to JavaScript and never travel in a URL. Sessions are revocable and expire server-side. - Every state-changing request (POST/PUT/DELETE) is CSRF-protected with a
per-session token (
X-CSRF-Tokenheader for htmx, hiddencsrf_tokenfield for forms). The one state-changing GET (the consolidation/log SSE streams) is guarded by a separate session-derived stream token. - Admin bootstrap is deterministic: the user whose email matches
ADMIN_EMAILis (and stays) the global admin. There is no "first user wins" race. All other role changes go through the admin Users page, which refuses to demote the last admin. dev-login(which bypasses OAuth) is only mounted whenDEV_MODE=trueand is the single most important thing to keep disabled in production.
See ADR-0004 for the rationale.
- Security headers on every response: a strict
Content-Security-Policy(script-src 'self'— all JS is self-hosted, no inline scripts),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin, and a restrictivePermissions-Policy. - Rate limiting (per-IP, in-memory): tight on auth endpoints, a cap on feedback submissions, and a backstop on all authenticated routes. A reverse proxy should still throttle in front for production.
- Static analysis:
govulncheckandgosecrun clean (reviewed false positives are annotated with#nosec+ justification).
The product promise is "reviewers can be honest because their identity never reaches the subject." That promise is enforced in code, not only by policy.
| Table | Contains | Identifies a reviewer? |
|---|---|---|
users |
Google profile (name, email, photo), role, timestamps | Only the logged-in identity |
sessions |
Session ID, user ID, expiry | No feedback content |
feedback_rounds / round_reviewers |
Subject, creator, template, status; reviewer assignments | Assignment only, no content |
submissions |
round_id, reviewer_id, responses/ratings (jsonb), private_notes |
Yes — see below |
consolidations |
AI/aggregate summary, admin notes, manager-only channel | No reviewer identity |
moderation_logs |
Per-submission scrub audit (reasons, fields scrubbed) | No content, no reviewer name |
audit_logs |
Actor, action, round, before/after values | Actor only (status transitions, not reviewers) |
submissions.reviewer_id exists to enforce "one submission per reviewer per
round" and to show reviewers their own draft. It is never surfaced to the
round subject; handler-level checks gate every read path.
| Role | Own submissions | Others' submissions | Consolidated feedback | Manager-only channel |
|---|---|---|---|---|
| Admin | Yes | Yes (for audit) | All rounds | Yes |
| Team Admin | Yes | Rounds they created | Rounds they created | Rounds they created |
| Member (reviewer) | Yes | No | Only shared with them as subject | No |
| Member (subject) | n/a | No | Yes, after sharing | Never |
Two invariants are enforced in internal/handlers and covered by tests: the
manager-only channel is stripped from anything the subject sees (in-app and
PDF), and reviewer identities are never returned to the subject.
- The PDF export contains only the consolidated summary — no raw submissions, no reviewer identifiers (and no manager-only section in the subject's copy).
- Audit logs record status transitions, not submission contents.
AI is triggered by an admin after a round closes — there is no automatic /
background processing. Two passes run (see ADR-0006),
both built in internal/ai:
- Moderation scrub (
moderation.go) — each submission is sent individually and Gemini returns a cleaned version with identity-targeted / personality- attack / off-topic content removed. Recorded inmoderation_logs. - Synthesis (
synthesis.go) — the scrubbed submissions are combined into the consolidation.
Per submission: the free-text question answers, competency rating justifications, the reviewer's relationship and interaction-frequency labels (needed so the model can weight signal), and any private manager-only note. What is not sent:
- The subject's or reviewer's name, email, photo, or any user ID.
- The round ID, team name, or organization name.
- Any audit-log or timestamp metadata.
Submissions are presented as unordered blocks; the response is constrained to a typed JSON schema so the model cannot reflect metadata into a structured field.
The Gemini SDK embeds the API key in request URLs, which appear in its error
strings. ai.sanitiseErr scrubs key=… to key=REDACTED before any error is
logged or persisted to moderation_logs, so the live key can't leak through the
audit trail.
If GEMINI_API_KEY is unset, moderation is a no-op and synthesis falls back to a
non-AI combine of the raw answers. No feedback content leaves the process unless
the operator enables Gemini.
Once data is sent to Gemini, Google's API terms and privacy policy apply:
As of the gemini-flash-latest model used here, the free tier excludes API
content from training; verify this is still true at the time you deploy.
Things the application can't enforce on your behalf:
- Generate a strong
SESSION_SECRET(openssl rand -hex 32) and rotate it on any suspected compromise. - Use a strong Postgres password and restrict network access to the database.
- Terminate TLS at a reverse proxy (Caddy / nginx + Let's Encrypt). OAuth
requires HTTPS in production; set
APP_URL/GOOGLE_REDIRECT_URLto the public HTTPS URLs. - Set
ADMIN_EMAILto the owner's address before first sign-in. - The app rate-limits per IP already; for extra depth, also throttle at the
reverse proxy (
rate_limitin Caddy,limit_reqin nginx). - Schedule an off-host backup with
scripts/backup.sh(see the deployment guide's "Backups & disaster recovery") and test a restore periodically. - Never set
DEV_MODE=truein production — it unlocksdev-loginand relaxes the Secure cookie flag.