diff --git a/db/capabilities.yml b/db/capabilities.yml new file mode 100644 index 00000000..a8c8abde --- /dev/null +++ b/db/capabilities.yml @@ -0,0 +1,4 @@ +capabilities: + channel_manager: + label: Channel manager lookups + covers: Apps can ask whether you manage a given channel. They are told yes, no, or nothing. diff --git a/db/flags.yml b/db/flags.yml index b6bf80f6..ad0cd677 100644 --- a/db/flags.yml +++ b/db/flags.yml @@ -11,3 +11,8 @@ flags: label: Decisions covers: The decisions section, and linking a case to one default: true + public_api: + label: Public API + covers: Channel manager lookups, tokens and opt-in + audience: Everyone in Slack + default: false diff --git a/db/init.sql b/db/init.sql index 8b26018f..532aa055 100644 --- a/db/init.sql +++ b/db/init.sql @@ -2,6 +2,7 @@ CREATE SCHEMA IF NOT EXISTS raw; CREATE SCHEMA IF NOT EXISTS analytics; CREATE SCHEMA IF NOT EXISTS app; CREATE SCHEMA IF NOT EXISTS fd; +CREATE SCHEMA IF NOT EXISTS api; DO $$ DECLARE @@ -71,6 +72,25 @@ BEGIN EXECUTE 'GRANT USAGE ON ALL SEQUENCES IN SCHEMA fd TO rails_app'; EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA fd GRANT USAGE ON SEQUENCES TO rails_app'; + EXECUTE 'GRANT USAGE ON SCHEMA api TO rails_app'; + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA api TO rails_app'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api ' + 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rails_app'; + EXECUTE 'GRANT USAGE ON ALL SEQUENCES IN SCHEMA api TO rails_app'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api GRANT USAGE ON SEQUENCES TO rails_app'; + + EXECUTE 'GRANT USAGE ON SCHEMA api TO pipeline_writer'; + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA api ' + 'TO pipeline_writer'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api ' + 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pipeline_writer'; + EXECUTE 'GRANT USAGE ON ALL SEQUENCES IN SCHEMA api TO pipeline_writer'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api ' + 'GRANT USAGE ON SEQUENCES TO pipeline_writer'; + + EXECUTE 'REVOKE ALL ON ALL TABLES IN SCHEMA api FROM dbt_owner'; + EXECUTE 'REVOKE ALL ON SCHEMA api FROM dbt_owner'; + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'app' AND tablename = 'sync_request') THEN EXECUTE 'GRANT SELECT, UPDATE ON app.sync_request TO pipeline_writer'; END IF; diff --git a/db/migrations/0055_api_and_channel_managers.sql b/db/migrations/0055_api_and_channel_managers.sql new file mode 100644 index 00000000..ee7f2c10 --- /dev/null +++ b/db/migrations/0055_api_and_channel_managers.sql @@ -0,0 +1,82 @@ +CREATE SCHEMA IF NOT EXISTS api; + +CREATE TABLE api.channel_manager ( + channel_id text NOT NULL, + user_id text NOT NULL, + assigned_at timestamptz, + PRIMARY KEY (channel_id, user_id) +); + +CREATE INDEX channel_manager_user_idx ON api.channel_manager (user_id); + +CREATE TABLE api.channel_sweep ( + channel_id text PRIMARY KEY, + synced_at timestamptz NOT NULL DEFAULT now(), + managers integer NOT NULL DEFAULT 0 +); + +CREATE TABLE api.consent ( + user_id text NOT NULL, + capability text NOT NULL, + state text NOT NULL, + changed_at timestamptz NOT NULL DEFAULT now(), + changed_via text NOT NULL, + first_granted_at timestamptz, + PRIMARY KEY (user_id, capability), + CONSTRAINT consent_state CHECK (state IN ('granted', 'withheld')) +); + +CREATE INDEX consent_granted_idx ON api.consent (capability) + WHERE state = 'granted'; + +CREATE TABLE api.consent_log ( + id bigserial PRIMARY KEY, + user_id text NOT NULL, + capability text NOT NULL, + state text NOT NULL, + via text NOT NULL, + at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX consent_log_at_idx ON api.consent_log (at DESC); + +CREATE TABLE api.token ( + id bigserial PRIMARY KEY, + owner_user_id text NOT NULL, + name text NOT NULL, + prefix text NOT NULL, + digest text NOT NULL UNIQUE, + rate_limit integer, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + revoked_at timestamptz, + revoked_by text, + CONSTRAINT token_rate_limit_sane CHECK (rate_limit IS NULL OR rate_limit > 0) +); + +CREATE INDEX token_owner_idx ON api.token (owner_user_id) + WHERE revoked_at IS NULL; + +CREATE TABLE api.setting ( + key text PRIMARY KEY, + value integer NOT NULL, + changed_by text, + changed_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO api.setting (key, value) VALUES + ('rate_per_minute', 100), + ('batch_max', 100), + ('tokens_per_owner', 3); + +CREATE TABLE api.request_log ( + id bigserial PRIMARY KEY, + token_id bigint NOT NULL REFERENCES api.token (id), + channel_id text, + subject_user_id text, + outcome text NOT NULL, + at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX request_log_subject_idx ON api.request_log (subject_user_id, at DESC); +CREATE INDEX request_log_token_idx ON api.request_log (token_id, at DESC); diff --git a/db/migrations/0056_api_event_log.sql b/db/migrations/0056_api_event_log.sql new file mode 100644 index 00000000..a6c2df72 --- /dev/null +++ b/db/migrations/0056_api_event_log.sql @@ -0,0 +1,10 @@ +CREATE TABLE api.event_log ( + id bigserial PRIMARY KEY, + actor_user_id text, + verb text NOT NULL, + subject text, + detail text, + at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX event_log_at_idx ON api.event_log (at DESC); diff --git a/db/migrations/0057_api_rate_default.sql b/db/migrations/0057_api_rate_default.sql new file mode 100644 index 00000000..8a1679af --- /dev/null +++ b/db/migrations/0057_api_rate_default.sql @@ -0,0 +1,4 @@ +UPDATE api.setting +SET value = 20 +WHERE key = 'rate_per_minute' + AND value = 100; diff --git a/db/migrations/0058_api_drop_batch_max.sql b/db/migrations/0058_api_drop_batch_max.sql new file mode 100644 index 00000000..ef51d329 --- /dev/null +++ b/db/migrations/0058_api_drop_batch_max.sql @@ -0,0 +1 @@ +DELETE FROM api.setting WHERE key = 'batch_max'; diff --git a/db/migrations/0059_api_token_expiry.sql b/db/migrations/0059_api_token_expiry.sql new file mode 100644 index 00000000..c36629a5 --- /dev/null +++ b/db/migrations/0059_api_token_expiry.sql @@ -0,0 +1,4 @@ +ALTER TABLE api.token ADD COLUMN expires_at timestamptz; + +CREATE INDEX token_expiry_idx ON api.token (expires_at) + WHERE revoked_at IS NULL AND expires_at IS NOT NULL; diff --git a/deploy/env/serve.env.example b/deploy/env/serve.env.example index 4f740cfd..2b542538 100644 --- a/deploy/env/serve.env.example +++ b/deploy/env/serve.env.example @@ -28,6 +28,7 @@ FD_ENCRYPTION_DETERMINISTIC_KEY= FD_ENCRYPTION_SALT= INTERNAL_PROXY_URL= SLACKSCAN_URL= +SLACK_CHANNEL_MANAGER_ROLE_ID= PROXY_TOKEN_WEB= PROXY_ALLOW_PLAINTEXT= RAILS_MAX_THREADS=5 diff --git a/pipeline/checks/slack_roles.py b/pipeline/checks/slack_roles.py new file mode 100644 index 00000000..d4b68c76 --- /dev/null +++ b/pipeline/checks/slack_roles.py @@ -0,0 +1,168 @@ +import argparse +import os +import sys +from collections import defaultdict + +from dotenv import load_dotenv +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from lib.paths import ENV_FILE + +PAGE_SIZE = 200 +SAMPLES = 3 +KINDS = {"C": "channel", "G": "private group", "T": "workspace", "E": "enterprise"} + +BLOCKING = { + "missing_scope", + "not_allowed_token_type", + "invalid_auth", + "account_inactive", + "token_revoked", + "not_authed", +} + + +def die(message): + sys.exit(f"slack_roles: {message}") + + +def api(token): + if not token: + die("no token. pass one as an argument or set SLACK_TOKEN to an xoxp user token") + return WebClient(token=token) + + +def whoami(client): + try: + said = client.auth_test().data + except SlackApiError as exc: + die(f"auth.test failed: {exc.response.get('error', 'unknown_error')}") + return said + + +def call(client, **params): + try: + return client.admin_roles_listAssignments(limit=PAGE_SIZE, **params) + except SlackApiError as exc: + error = exc.response.get("error", "unknown_error") + if error == "missing_scope": + needed = exc.response.get("needed", "admin.roles:read") + die(f"the token is missing {needed}. add it and reinstall the app") + if error in BLOCKING: + die(f"admin.roles.listAssignments refused this token: {error}") + die(f"admin.roles.listAssignments failed: {error}") + + +def walk(client, pages=None, **params): + cursor = "" + seen = 0 + while True: + page = call(client, cursor=cursor or None, **params) + for one in page.get("role_assignments", []): + yield one + seen += 1 + cursor = page.get("response_metadata", {}).get("next_cursor") or "" + if not cursor or (pages is not None and seen >= pages): + return + + +def kind_of(entity_id): + return KINDS.get(entity_id[:1], entity_id[:1] or "?") + + +def gather(client, pages): + found = defaultdict(lambda: {"count": 0, "entities": set(), "kinds": set(), "users": set()}) + for one in walk(client, pages=pages): + entity = one.get("entity_id", "") + role = found[one.get("role_id", "?")] + role["count"] += 1 + role["entities"].add(entity) + role["kinds"].add(kind_of(entity)) + role["users"].add(one.get("user_id", "")) + return found + + +def report(found, capped): + print() + print(f" {'role_id':<10}{'assigned':>10}{'entities':>10} {'kind':<14}samples") + for role_id, one in sorted(found.items(), key=lambda pair: -pair[1]["count"]): + kinds = ", ".join(sorted(one["kinds"])) + samples = ", ".join(sorted(one["entities"])[:SAMPLES]) + print(f" {role_id:<10}{one['count']:>10}{len(one['entities']):>10} {kinds:<14}{samples}") + if capped: + print(f"\n counts cover the first {capped} pages only. pass --all to sweep everything.") + + +def channel_role(found): + named = [role for role, one in found.items() if "channel" in one["kinds"]] + if len(named) == 1: + return named[0] + if not named: + print("\n no role has channel entities. nothing here maps to channel manager.") + return None + print(f"\n more than one role has channel entities: {', '.join(sorted(named))}") + print(" pass --role to check one of them against a channel you know.") + return None + + +def check_filter(client, role_id, channel_id): + print(f"\n filter check: role_ids={role_id} entity_ids={channel_id}") + rows = list(walk(client, role_ids=[role_id], entity_ids=[channel_id])) + if not rows: + print(" nothing came back. the filter may not combine, or nobody manages that channel.") + return + stray = {row.get("entity_id") for row in rows} - {channel_id} + users = sorted({row.get("user_id", "") for row in rows}) + print(f" {len(rows)} assignments, {len(users)} people") + print(f" managers: {', '.join(users[:8])}{' ...' if len(users) > 8 else ''}") + if stray: + print(f" WARNING entity_ids did not narrow it. also returned: {', '.join(sorted(stray)[:5])}") + else: + print(" every row is on that channel, so one call answers one channel") + + +def main(): + parser = argparse.ArgumentParser( + prog="slack_roles", + description="find which Slack role id means channel manager, and prove that " + "role_ids and entity_ids together answer one channel in one call", + ) + parser.add_argument("token", nargs="?", help="an xoxp user token, else SLACK_TOKEN is used") + parser.add_argument("--all", action="store_true", help="sweep every page instead of the first few") + parser.add_argument("--pages", type=int, default=10, help="pages to sample, 200 per page") + parser.add_argument("--role", help="skip discovery and check this role id") + parser.add_argument("--channel", help="channel id for the filter check") + args = parser.parse_args() + + load_dotenv(ENV_FILE) + client = api(args.token or os.environ.get("SLACK_TOKEN", "").strip()) + who = whoami(client) + print(f"admin.roles.listAssignments as {who.get('user')} ({who.get('user_id')}) " + f"on {who.get('team')} / {who.get('team_id')}") + + role_id = args.role + channel_id = args.channel + + if role_id is None: + pages = None if args.all else args.pages + found = gather(client, pages) + if not found: + die("no role assignments came back at all") + report(found, None if args.all else args.pages) + role_id = channel_role(found) + if role_id is None: + return + if channel_id is None: + channel_id = sorted(found[role_id]["entities"])[0] + print(f"\n channel manager looks like {role_id}") + print(f" pin it: SLACK_CHANNEL_MANAGER_ROLE_ID={role_id}") + + if channel_id is None: + die("pass --channel with --role so the filter can be checked") + + check_filter(client, role_id, channel_id) + + +if __name__ == "__main__": + main() diff --git a/pipeline/lib/config.py b/pipeline/lib/config.py index 193f74f4..9855d58a 100644 --- a/pipeline/lib/config.py +++ b/pipeline/lib/config.py @@ -33,6 +33,7 @@ "FD_ENCRYPTION_SALT", "INTERNAL_PROXY_URL", "SLACKSCAN_URL", + "SLACK_CHANNEL_MANAGER_ROLE_ID", "PROXY_TOKEN_WEB", "PROXY_ALLOW_PLAINTEXT", "RAILS_MAX_THREADS", diff --git a/pipeline/slack.manifest.yaml b/pipeline/slack.manifest.yaml index 23596050..614d5172 100644 --- a/pipeline/slack.manifest.yaml +++ b/pipeline/slack.manifest.yaml @@ -17,6 +17,7 @@ oauth_config: - admin.analytics:read - admin.users:read - admin.teams:read + - admin.roles:read settings: event_subscriptions: diff --git a/proxy/app.py b/proxy/app.py index 555b3508..feaf0aba 100644 --- a/proxy/app.py +++ b/proxy/app.py @@ -29,6 +29,7 @@ "admin": frozenset( { "admin.users.list", + "admin.roles.listAssignments", "search.messages", "conversations.replies", } @@ -44,6 +45,11 @@ "admin.analytics.getAvailableDateRange", } ), + "admin": frozenset( + { + "admin.roles.listAssignments", + } + ), } CREDENTIALS = ("internal", "admin") diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index 8baea713..95eccf61 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -2755,6 +2755,400 @@ border-bottom: 0; } + .cap-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 11px 14px; + border-bottom: 1px solid var(--line-soft); + } + + .cap-row:last-child { + border-bottom: 0; + } + + .cap-two { + display: grid; + gap: 1px; + min-width: 0; + } + + .cap-two b { + font-size: var(--t-body); + font-weight: 600; + } + + .cap-two span { + font-size: var(--t-small); + color: var(--ink-3); + } + + .cap-do { + display: flex; + align-items: center; + gap: var(--s-2xs); + flex: none; + } + + .btn-crit { + background-color: var(--sev-crit); + border-color: var(--sev-crit); + color: var(--accent-on); + } + + .btn-crit:hover { + background-color: var(--sev-crit-deep); + border-color: var(--sev-crit-deep); + color: var(--accent-on); + } + + .drop { + position: relative; + display: grid; + } + + .drop::after { + content: ""; + position: absolute; + inset-inline-end: 12px; + top: 50%; + width: 8px; + height: 8px; + border-inline-end: 2px solid var(--ink-3); + border-block-end: 2px solid var(--ink-3); + transform: translateY(-70%) rotate(45deg); + pointer-events: none; + } + + .drop-in { + appearance: none; + width: 100%; + font-family: inherit; + font-size: var(--t-body); + line-height: 1.25; + color: var(--ink); + background-color: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-ctl); + padding: 9px 32px 9px var(--s-xs); + cursor: pointer; + transition: border-color var(--dur) var(--ease-out); + } + + .drop-in:hover { + border-color: var(--ink-3); + } + + .drop-in:focus-visible { + outline: none; + border-color: var(--focus); + box-shadow: 0 0 0 3px var(--accent-soft); + } + + .drop:has(.drop-in:hover)::after { + border-color: var(--ink); + } + + .form-body { + gap: var(--s-md); + } + + .form-body .field-label { + gap: 6px; + } + + .warn-line { + display: flex; + align-items: flex-start; + gap: var(--s-2xs); + margin: 0; + padding: 10px 12px; + border-radius: var(--r-ctl); + background-color: var(--warn-soft); + color: var(--warn); + font-size: var(--t-small); + font-weight: 500; + line-height: 1.45; + } + + .warn-line svg { + flex: none; + margin-top: 1px; + } + + .secret { + display: flex; + align-items: center; + gap: var(--s-2xs); + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: var(--r-ctl); + background-color: var(--card-2); + overflow-x: auto; + } + + .secret code { + flex: 1; + min-width: 0; + font-family: var(--font-code); + font-size: var(--t-small); + color: var(--ink); + } + + .secret .btn { + height: 24px; + flex: none; + } + + .docs { + display: grid; + grid-template-columns: 196px minmax(0, 1fr); + gap: var(--s-xl); + align-items: start; + } + + @media (max-width: 900px) { + .docs { grid-template-columns: minmax(0, 1fr); gap: var(--s-md); } + .docs-nav { position: static; } + } + + .docs-nav { + position: sticky; + top: 0; + display: grid; + gap: 1px; + align-content: start; + padding-block: 2px; + } + + .docs-nav-h { + font-size: var(--t-fine); + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--ink-4); + margin: 0 0 5px; + padding: 0 8px; + } + + .docs-nav-h + .docs-nav-h, + .docs-nav a + .docs-nav-h { + margin-top: var(--s-sm); + } + + .docs-nav a { + font-size: var(--t-small); + color: var(--ink-2); + text-decoration: none; + padding: 5px 8px; + border-radius: var(--r-ctl); + border-inline-start: 2px solid transparent; + } + + .docs-nav a:hover { + background-color: var(--hover); + color: var(--ink); + } + + .docs-nav a:target, + .docs-nav a:focus-visible { + color: var(--accent); + } + + .docs-body { + min-width: 0; + display: grid; + gap: var(--s-xl); + max-width: 74ch; + } + + .docs-lede { + display: grid; + gap: var(--s-xs); + padding-bottom: var(--s-md); + border-bottom: 1px solid var(--line); + } + + .docs-lede h1 { + font-size: 1.5rem; + font-weight: 650; + letter-spacing: -0.025em; + margin: 0; + text-wrap: balance; + } + + .docs-lede p { + margin: 0; + font-size: var(--t-body); + line-height: 1.6; + color: var(--ink-2); + } + + .docs-meta { + display: flex; + flex-wrap: wrap; + gap: var(--s-2xs) var(--s-md); + margin: var(--s-2xs) 0 0; + } + + .docs-meta div { + display: grid; + gap: 1px; + } + + .docs-meta dt { + font-family: var(--font-code); + font-size: var(--t-micro); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-4); + } + + .docs-meta dd { + margin: 0; + font-size: var(--t-small); + font-weight: 500; + } + + .docs-sec { + display: grid; + gap: var(--s-xs); + scroll-margin-top: var(--s-sm); + } + + .docs-sec h2 { + display: flex; + align-items: center; + gap: var(--s-2xs); + font-size: var(--t-title); + font-weight: 620; + letter-spacing: -0.018em; + margin: 0; + } + + .docs-sec p { + margin: 0; + font-size: var(--t-body); + line-height: 1.65; + color: var(--ink-2); + } + + .docs-sec p.lede { + color: var(--ink); + font-weight: 500; + } + + .docs-sec code { + font-family: var(--font-code); + font-size: 0.86em; + background-color: var(--card-2); + border: 1px solid var(--line-soft); + border-radius: 4px; + padding: 0 4px; + } + + .docs-sec .data-table th, + .docs-sec .data-table td { + width: 1%; + white-space: nowrap; + vertical-align: top; + } + + .docs-sec .data-table th.said-cell, + .docs-sec .data-table td.said-cell { + width: auto; + white-space: normal; + padding-inline-start: var(--s-md); + } + + .docs-sec .data-table td.mono { + color: var(--ink); + } + + .docs-sec .scroll { + border: 1px solid var(--line); + border-radius: var(--r-card); + background-color: var(--card); + } + + .docs-note { + border-inline-start: 2px solid var(--accent); + padding-inline-start: var(--s-xs); + } + + .docs-case { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 5px; + min-width: 0; + } + + .docs-case .pre { + height: 100%; + } + + .docs-case-h { + font-size: var(--t-fine) !important; + font-weight: 600 !important; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-3) !important; + } + + .verb { + font-family: var(--font-code); + font-size: var(--t-micro); + font-weight: 700; + letter-spacing: 0.08em; + padding: 3px 6px; + border-radius: 4px; + background-color: var(--accent-soft); + color: var(--accent); + flex: none; + } + + .pre { + margin: 0; + padding: 12px 14px; + border-radius: var(--r-card); + overflow-x: auto; + background-color: var(--card); + border: 1px solid var(--line); + font-family: var(--font-code); + font-size: var(--t-fine); + line-height: 1.7; + color: var(--ink); + } + + .pre .k { color: var(--accent); } + .pre .s { color: var(--good); } + .pre .c { color: var(--ink-3); font-style: italic; } + .pre .n { color: var(--warn); } + + .two-up { + display: grid; + gap: var(--s-xs); + grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr)); + } + + .num-in { + font-family: var(--font-code); + font-variant-numeric: tabular-nums; + max-width: 8rem; + } + + .rate-own { + color: var(--accent); + font-weight: 600; + } + + .secret-warn { + margin: 0; + font-size: var(--t-small); + color: var(--warn); + } + .row-k { color: var(--ink-3); } .row-v { diff --git a/web/app/controllers/api/v1/base_controller.rb b/web/app/controllers/api/v1/base_controller.rb new file mode 100644 index 00000000..25639ab6 --- /dev/null +++ b/web/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,84 @@ +module Api + module V1 + class BaseController < ActionController::API + BEARER = /\ABearer (.+)\z/i + + WINDOW = 60 + KEPT_FOR = 2.minutes + + before_action :require_a_token + before_action :within_budget + + private + + attr_reader :current_token + + def within_budget + limit = current_token.rate + used = Rails.cache.increment(bucket, 1, expires_in: KEPT_FOR).to_i + budget!(limit, used) + return if used <= limit + + response.headers["Retry-After"] = left.to_s + refuse(:too_many_requests, "rate_limited", retry_after: left) + end + + def bucket + "api/rate/#{current_token.id}/#{Time.current.to_i / WINDOW}" + end + + def left + WINDOW - (Time.current.to_i % WINDOW) + end + + def budget!(limit, used) + response.headers["RateLimit-Limit"] = limit.to_s + response.headers["RateLimit-Remaining"] = [limit - used, 0].max.to_s + response.headers["RateLimit-Reset"] = left.to_s + end + + def require_a_token + return refuse(:service_unavailable, "api_off") if Fd::Flag.off?(:public_api) + + key = presented + return refuse(:unauthorized, "invalid_token") if key.blank? + + token = ::Api::Token.find_by(digest: ::Api::Token.digest_of(key)) + return refuse(:unauthorized, "invalid_token") if token.nil? + return refuse(:unauthorized, "revoked_token") if token.revoked? + return refuse(:unauthorized, "expired_token") if token.expired? + + @current_token = token + token.used! + end + + def presented + request.authorization.to_s[BEARER, 1] + end + + SAID = { + "api_off" => "the public_api flag is off", + "invalid_token" => "no live key matches that digest", + "revoked_token" => "that key was revoked, do not retry", + "expired_token" => "that key reached its expiry date, mint a new one", + "bad_channel_id" => "channel_id must match /\\AC[A-Z0-9]{8,}\\z/", + "bad_user_id" => "every user id must match /\\A[UW][A-Z0-9]{8,}\\z/", + "rate_limited" => "rate limit spent, see retry_after" + }.freeze + + CALLER_ERRORS = [ + [401, "invalid_token", "Absent, malformed, or unknown Authorization header."], + [401, "revoked_token", "Key exists but was revoked. Not retryable."], + [401, "expired_token", "Key passed its expiry date. Mint a new one."], + [422, "bad_channel_id", "channel_id did not match the pattern above."], + [422, "bad_user_id", "user_id did not match the pattern above."], + [429, "rate_limited", "Budget spent. Retry after retry_after seconds."], + [503, "api_off", "The public_api flag is off. Applies to every route."] + ].freeze + + def refuse(status, error, **extra) + render json: { error: error, message: SAID[error] }.compact.merge(extra), status: status + end + end + end +end diff --git a/web/app/controllers/api/v1/channel_managers_controller.rb b/web/app/controllers/api/v1/channel_managers_controller.rb new file mode 100644 index 00000000..c822735d --- /dev/null +++ b/web/app/controllers/api/v1/channel_managers_controller.rb @@ -0,0 +1,48 @@ +module Api + module V1 + class ChannelManagersController < BaseController + CHANNEL = /\AC[A-Z0-9]{8,}\z/ + MEMBER = /\A[UW][A-Z0-9]{8,}\z/ + CAPABILITY = "channel_manager".freeze + + def show + channel = params[:channel_id].to_s + user = params[:user_id].to_s + + return refuse(:unprocessable_content, "bad_channel_id") unless CHANNEL.match?(channel) + return refuse(:unprocessable_content, "bad_user_id") unless MEMBER.match?(user) + + render json: { channel_id: channel, user_id: user }.merge(answer(channel, user)) + end + + private + + def answer(channel, user) + return withheld(channel, user) unless ::Api::Consent.granted?(user, CAPABILITY) + + ChannelManagers.freshen(channel) + held = ::Api::ChannelManager.find_by(channel_id: channel, user_id: user) + log(channel, user, held ? ::Api::RequestLog::MANAGER : ::Api::RequestLog::NOT_MANAGER) + + { consent: "granted", is_manager: held.present? }.merge(freshness(channel)) + end + + def withheld(channel, user) + log(channel, user, ::Api::RequestLog::WITHHELD) + + { consent: "withheld", is_manager: nil, opt_in_url: "#{request.base_url}/you/api" } + end + + def freshness(channel) + swept = ChannelManagers.swept_at(channel) + + { synced_at: swept&.utc&.iso8601, + stale: swept.nil? || swept < ChannelManagers::TTL.ago } + end + + def log(channel, user, outcome) + ::Api::RequestLog.log!(current_token.id, channel, [[user, outcome]]) + end + end + end +end diff --git a/web/app/controllers/api/v1/tokens_controller.rb b/web/app/controllers/api/v1/tokens_controller.rb new file mode 100644 index 00000000..88379930 --- /dev/null +++ b/web/app/controllers/api/v1/tokens_controller.rb @@ -0,0 +1,16 @@ +module Api + module V1 + class TokensController < BaseController + def show + render json: { + name: current_token.name, + prefix: current_token.prefix, + owner_user_id: current_token.owner_user_id, + rate_per_minute: current_token.rate, + created_at: current_token.created_at.utc.iso8601, + last_used_at: current_token.last_used_at&.utc&.iso8601 + } + end + end + end +end diff --git a/web/app/controllers/dev_sessions_controller.rb b/web/app/controllers/dev_sessions_controller.rb index b066fafa..fc41827b 100644 --- a/web/app/controllers/dev_sessions_controller.rb +++ b/web/app/controllers/dev_sessions_controller.rb @@ -6,10 +6,15 @@ def create staff = Staff.find_or_initialize_by(user_id: params[:user_id]) reset_session + unless You::BaseController::MEMBER_ID.match?(staff.user_id) + return redirect_to login_path, alert: "#{staff.user_id} is not a slack id" + end + + session[:user_id] = staff.user_id + if staff.role.nil? - redirect_to login_path, alert: "#{staff.user_id} is not allowlisted" + redirect_to you_api_path, notice: "signed in as #{staff.user_id}, no role" else - session[:user_id] = staff.user_id redirect_to fd_root_path, notice: "signed in as #{staff.user_id}, #{staff.role}" end end diff --git a/web/app/controllers/docs_controller.rb b/web/app/controllers/docs_controller.rb new file mode 100644 index 00000000..66e9bc93 --- /dev/null +++ b/web/app/controllers/docs_controller.rb @@ -0,0 +1,5 @@ +class DocsController < You::BaseController + def show + @rate = Api::Setting.value("rate_per_minute") + end +end diff --git a/web/app/controllers/fd/api_settings_controller.rb b/web/app/controllers/fd/api_settings_controller.rb new file mode 100644 index 00000000..8f2451ed --- /dev/null +++ b/web/app/controllers/fd/api_settings_controller.rb @@ -0,0 +1,72 @@ +module Fd + class ApiSettingsController < BaseController + skip_before_action :needs_the_engine + permit "access.grant" + + MOST = 100_000 + + def update + key = params[:key].to_s + return refuse("#{key} is not a setting") unless ::Api::Setting::DEFAULTS.key?(key) + + value = params[:value].to_i + return refuse("a setting has to be a number above nought") unless value.positive? + return refuse("#{value} is more than anybody needs") if value > MOST + + was = ::Api::Setting.value(key) + writing do + ::Api::Setting.set!(key, value, by: current_staff.user_id) + ::Api::Event.record!("setting_changed", actor: current_staff.user_id, + subject: key.tr("_", " "), detail: "#{was} to #{value}") + end + + back_to "#{key.tr('_', ' ')} is now #{value}" + end + + def rate + token = ::Api::Token.find_by(id: params[:id]) + return refuse("no such token") if token.nil? + + value = params[:value].presence&.to_i + return refuse("a rate has to be a number above nought") if value && !value.positive? + + was = token.rate + writing do + token.update!(rate_limit: value) + ::Api::Event.record!("token_rate_set", actor: current_staff.user_id, + subject: token.shown, detail: said_rate(token, was, value)) + end + + back_to "#{token.name} is now #{token.rate} a minute" + end + + def destroy + token = ::Api::Token.live.find_by(id: params[:id]) + return refuse("no live token with that id") if token.nil? + + writing do + token.revoke!(by: current_staff.user_id) + ::Api::Event.record!("token_revoked", actor: current_staff.user_id, + subject: token.shown, detail: "#{token.name}, owned by #{token.owner_user_id}") + end + + back_to "Revoked #{token.name}" + end + + private + + def said_rate(token, was, value) + return "#{token.name}, back to the shared #{token.rate}" if value.nil? + + "#{token.name}, #{was} to #{value}" + end + + def back_to(said) + redirect_to fd_settings_path(tab: "api"), notice: said + end + + def refuse(said) + redirect_to fd_settings_path(tab: "api"), alert: said + end + end +end diff --git a/web/app/controllers/fd/audits_controller.rb b/web/app/controllers/fd/audits_controller.rb index 5d6d19bb..b7e81de7 100644 --- a/web/app/controllers/fd/audits_controller.rb +++ b/web/app/controllers/fd/audits_controller.rb @@ -6,12 +6,14 @@ class AuditsController < BaseController PER_PAGE = 100 def show - counting = Deeds.new(nil, since: WINDOW.ago) - @total = counting.total + @counts = Deeds.new(nil, since: WINDOW.ago).totals + @view = Deeds.view_for(params[:view]) + @seen = @view unless @view == "all" + @total = @counts.fetch(@view, 0) @pages = [(@total / PER_PAGE.to_f).ceil, 1].max @page = [params[:page].to_i, 1].max.clamp(1, @pages) - @deeds = Deeds.new(nil, since: WINDOW.ago, limit: PER_PAGE, + @deeds = Deeds.new(nil, since: WINDOW.ago, view: @view, limit: PER_PAGE, offset: (@page - 1) * PER_PAGE) @rows = @deeds.rows @names = Names.for(@deeds.member_ids) diff --git a/web/app/controllers/fd/settings_controller.rb b/web/app/controllers/fd/settings_controller.rb index fbb3feeb..bfa00310 100644 --- a/web/app/controllers/fd/settings_controller.rb +++ b/web/app/controllers/fd/settings_controller.rb @@ -5,7 +5,7 @@ class SettingsController < BaseController TABS = { "access" => "Access", "roles" => "Roles", "sections" => "Sections", "usage" => "Usage", "history" => "Grant history", - "activity" => "Activity", "you" => "You" }.freeze + "activity" => "Activity", "you" => "You", "api" => "API" }.freeze MINE = %w[you activity].freeze WINDOW = 30.days DORMANT_AFTER = 30.days @@ -32,6 +32,7 @@ def show @person = chosen grant_facts if @person @used = used_lately if @tab == "roles" + api_facts if @tab == "api" usage_facts if @tab == "usage" deed_facts if @person && @tab == "usage" @counts = tab_counts @@ -84,7 +85,19 @@ def mine_lately def tab_counts { "access" => @grants.size, "roles" => Permission.keys.size, - "history" => AccessGrant.count } + "history" => AccessGrant.count, "api" => ::Api::Token.live.count } + end + + def api_facts + @tokens = ::Api::Token.order(revoked_at: :asc, created_at: :desc).to_a + @asks = ::Api::RequestLog.where(at: WINDOW.ago..).group(:token_id).count + @opted_in = ::Api::Consent.where(state: ::Api::Consent::GRANTED).count + @checks = ::Api::RequestLog.where(at: WINDOW.ago..).count + @withheld = ::Api::RequestLog.where(at: WINDOW.ago.., outcome: "withheld").count + @channels = ::Api::RequestLog.where(at: WINDOW.ago..).distinct.count(:channel_id) + @synced = ::Api::ChannelSweep.maximum(:synced_at) + @dials = ::Api::Setting::DEFAULTS.keys.index_with { |key| ::Api::Setting.value(key) } + @last_dial = ::Api::Setting.order(changed_at: :desc).first end def tally_without_grants @@ -189,6 +202,7 @@ def holders def named holders + @grants.map(&:granted_by) + Array(@top_reader&.first) + + Array(@tokens).map(&:owner_user_id) + Array(@last_dial&.changed_by) + Array(@deeds&.member_ids) + Array(@history).flat_map { |grant| [grant.user_id, grant.granted_by, grant.revoked_by] diff --git a/web/app/controllers/sessions_controller.rb b/web/app/controllers/sessions_controller.rb index 34fcb005..f9f263b4 100644 --- a/web/app/controllers/sessions_controller.rb +++ b/web/app/controllers/sessions_controller.rb @@ -3,22 +3,27 @@ class SessionsController < ApplicationController skip_before_action :require_staff def new - redirect_to root_path if current_staff&.role.present? + if current_staff&.role.present? + redirect_to root_path + elsif session[:user_id].present? + redirect_to you_api_path + end end def create auth = request.env["omniauth.auth"] - slack_id = auth&.extra&.raw_info&.[]("slack_id") - staff = slack_id.present? ? Staff.find_or_initialize_by(user_id: slack_id) : nil + slack_id = auth&.extra&.raw_info&.[]("slack_id").to_s + return refuse("no_slack_id") unless You::BaseController::MEMBER_ID.match?(slack_id) + + staff = Staff.find_or_initialize_by(user_id: slack_id) + reset_session + session[:user_id] = staff.user_id - if staff&.role.present? - reset_session - session[:user_id] = staff.user_id + if staff.role.present? flash[:said] = "Everything you do from here is recorded against #{staff.user_id}." redirect_to root_path, notice: "Signed in as a #{staff.role.tr('_', ' ')}" else - reset_session - redirect_to auth_failure_path(message: "not_allowlisted") + redirect_to you_api_path, notice: "Signed in as #{staff.user_id}" end end @@ -31,4 +36,11 @@ def destroy reset_session redirect_to login_path end + + private + + def refuse(message) + reset_session + redirect_to auth_failure_path(message: message) + end end diff --git a/web/app/controllers/you/api_controller.rb b/web/app/controllers/you/api_controller.rb new file mode 100644 index 00000000..16c0165b --- /dev/null +++ b/web/app/controllers/you/api_controller.rb @@ -0,0 +1,12 @@ +module You + class ApiController < BaseController + TABS = { "permissions" => "Permissions", "tokens" => "Tokens" }.freeze + + def show + @tab = TABS.key?(params[:tab]) ? params[:tab] : "permissions" + @consents = Api::Consent.states_for(member_id) + @tokens = Api::Token.for_owner(member_id) + @rooms = SlackScan.channels(member_id) + end + end +end diff --git a/web/app/controllers/you/base_controller.rb b/web/app/controllers/you/base_controller.rb new file mode 100644 index 00000000..8b746ce2 --- /dev/null +++ b/web/app/controllers/you/base_controller.rb @@ -0,0 +1,25 @@ +module You + class BaseController < ApplicationController + MEMBER_ID = /\A[UW][A-Z0-9]{2,}\z/ + + skip_before_action :require_staff + before_action :require_a_member + + helper_method :member_id + + private + + def member_id + session[:user_id].to_s + end + + def require_a_member + return if MEMBER_ID.match?(member_id) + + reset_session + return head :unauthorized if request.format.json? + + redirect_to login_path, alert: "sign in to continue" + end + end +end diff --git a/web/app/controllers/you/consents_controller.rb b/web/app/controllers/you/consents_controller.rb new file mode 100644 index 00000000..67c3b0bf --- /dev/null +++ b/web/app/controllers/you/consents_controller.rb @@ -0,0 +1,27 @@ +module You + class ConsentsController < BaseController + def update + return redirect_to(you_api_path, alert: turned_off) if Fd::Flag.off?(:public_api) + + key = params[:capability].to_s + Api::Capability.fetch(key) + granted = params[:on] == "1" + Api::Consent.set!(member_id, key, granted, via: "dashboard") + + redirect_to you_api_path, notice: said(key, granted) + rescue Api::Capability::Unknown => e + redirect_to you_api_path, alert: e.message + end + + private + + def turned_off + "#{Fd::Flag.label(:public_api).downcase} is turned off" + end + + def said(key, granted) + named = Api::Capability.label(key).downcase + granted ? "Opted in to #{named}" : "Opted out of #{named}" + end + end +end diff --git a/web/app/controllers/you/tokens_controller.rb b/web/app/controllers/you/tokens_controller.rb new file mode 100644 index 00000000..756b9bc3 --- /dev/null +++ b/web/app/controllers/you/tokens_controller.rb @@ -0,0 +1,47 @@ +module You + class TokensController < BaseController + def create + return refuse(turned_off) if Fd::Flag.off?(:public_api) + return refuse("name what the token is for") if params[:name].to_s.strip.empty? + + token, @secret = Api::Token.mint!(member_id, params[:name], lasting: params[:lasting]) + Api::Event.record!("token_minted", actor: member_id, subject: token.shown, + detail: [token.name, Api::Token::LIFE_WORDS.fetch(Api::Token.life_for(params[:lasting]))] + .join(", ")) + + @token = token + show_again + rescue Api::Token::TooMany + refuse("you already hold #{Api::Setting.value('tokens_per_owner')} tokens") + end + + def destroy + token = Api::Token.live.find_by(id: params[:id], owner_user_id: member_id) + return refuse("that token is not yours") if token.nil? + + token.revoke!(by: member_id) + Api::Event.record!("token_revoked", actor: member_id, subject: token.shown, + detail: token.name) + + redirect_to you_api_path(tab: "tokens"), notice: "Revoked #{token.name}" + end + + private + + def turned_off + "#{Fd::Flag.label(:public_api).downcase} is turned off" + end + + def refuse(said) + redirect_to you_api_path(tab: "tokens"), alert: said + end + + def show_again + @tab = "tokens" + @consents = Api::Consent.states_for(member_id) + @tokens = Api::Token.for_owner(member_id) + @rooms = SlackScan.channels(member_id) + render "you/api/show" + end + end +end diff --git a/web/app/helpers/fd_helper.rb b/web/app/helpers/fd_helper.rb index d8e5a510..32344ecd 100644 --- a/web/app/helpers/fd_helper.rb +++ b/web/app/helpers/fd_helper.rb @@ -1149,6 +1149,13 @@ def acted_label(at) "decision/dropped" => "Dropped", "decision/settled" => "Settled", "decision/superseded" => "Retired", + "consent/granted" => "Opted in to", + "consent/withheld" => "Opted out of", + "api/checked" => "Checked", + "api/setting_changed" => "Changed the", + "api/token_minted" => "Generated a token,", + "api/token_rate_set" => "Set the rate on", + "api/token_revoked" => "Revoked a token,", "decision_thread/attached" => "Linked a thread to", "decision_thread/detached" => "Unlinked a thread from", "grant/granted" => "Gave access to", @@ -1213,6 +1220,7 @@ def deed_link(deed) case deed.kind when "case" then link_to deed.about, fd_case_path(deed.id), class: "lnk" when "decision" then link_to deed.about, fd_decision_path(deed.id), class: "lnk" + when "capability" then deed.about else member_link(deed.id) end end @@ -1221,6 +1229,55 @@ def deed_said(deed) [deed.said, ("on #{names[deed.who]}" if deed.who.present?)].compact.join(" ") end + DIAL_LABELS = { + "rate_per_minute" => "Requests a minute, per token", + "batch_max" => "People per batch call", + "tokens_per_owner" => "Live tokens per owner" + }.freeze + + def token_life_line(token) + return "never expires" if token.expires_at.nil? + + "expires #{token.expires_at.strftime('%-d %b %Y')}" + end + + def dial_label(key) + DIAL_LABELS.fetch(key, key.tr("_", " ")) + end + + def api_state_chip + on = Fd::Flag.on?(:public_api) + tag.span(class: "chip #{on ? 'chip-good' : 'chip-off'}") do + tag.span(class: "chip-dot", aria: { hidden: true }) + (on ? "On" : "Off") + end + end + + def withheld_share(withheld, checks) + return "none yet" if checks.zero? + + "#{(withheld * 100.0 / checks).round(1)}% of checks" + end + + def synced_line(at) + return "never" if at.nil? + + swept = Api::ChannelSweep.count + "#{at.strftime('%-d %b %H:%M')}, #{swept} #{'channel'.pluralize(swept)}" + end + + def dial_reach(key, tokens) + live = tokens.reject(&:revoked?) + return "#{live.count { |one| one.rate_limit.nil? }} of #{live.size}" if key == "rate_per_minute" + + "every caller" + end + + def dial_change(setting) + return "never" if setting.nil? || setting.changed_by.blank? + + "#{names[setting.changed_by]}, #{setting.changed_at.strftime('%-d %b')}" + end + def acted_line(at) at ? "acted #{last_case_label(at)}" : "nothing yet" end diff --git a/web/app/models/api/capability.rb b/web/app/models/api/capability.rb new file mode 100644 index 00000000..40f13bed --- /dev/null +++ b/web/app/models/api/capability.rb @@ -0,0 +1,21 @@ +module Api + class Capability + class Unknown < ArgumentError; end + + TABLE = YAML.load_file(Rails.root.join("../db/capabilities.yml")) + .fetch("capabilities").freeze + KEYS = TABLE.keys.freeze + + def self.fetch(key) + TABLE.fetch(key.to_s) { raise Unknown, "#{key} is not a capability" } + end + + def self.label(key) = fetch(key).fetch("label") + + def self.covers(key) = fetch(key).fetch("covers") + + def self.known?(key) = TABLE.key?(key.to_s) + + def self.said(key) = known?(key) ? label(key).downcase : key.to_s + end +end diff --git a/web/app/models/api/channel_manager.rb b/web/app/models/api/channel_manager.rb new file mode 100644 index 00000000..f90432c3 --- /dev/null +++ b/web/app/models/api/channel_manager.rb @@ -0,0 +1,10 @@ +module Api + class ChannelManager < ApplicationRecord + self.table_name = "api.channel_manager" + self.primary_key = [:channel_id, :user_id] + + def self.user_ids_in(channel_id) + where(channel_id: channel_id).order(:user_id).pluck(:user_id) + end + end +end diff --git a/web/app/models/api/channel_sweep.rb b/web/app/models/api/channel_sweep.rb new file mode 100644 index 00000000..14ee564b --- /dev/null +++ b/web/app/models/api/channel_sweep.rb @@ -0,0 +1,15 @@ +module Api + class ChannelSweep < ApplicationRecord + self.table_name = "api.channel_sweep" + self.primary_key = "channel_id" + + def self.stamp!(channel_id, managers) + upsert({ channel_id: channel_id, synced_at: Time.current, managers: managers }, + unique_by: :channel_id) + end + + def stale?(ttl) + synced_at < ttl.ago + end + end +end diff --git a/web/app/models/api/consent.rb b/web/app/models/api/consent.rb new file mode 100644 index 00000000..255f00ae --- /dev/null +++ b/web/app/models/api/consent.rb @@ -0,0 +1,36 @@ +module Api + class Consent < ApplicationRecord + self.table_name = "api.consent" + self.primary_key = [:user_id, :capability] + + GRANTED = "granted".freeze + WITHHELD = "withheld".freeze + + def self.granted?(user_id, capability) + exists?(user_id: user_id, capability: capability, state: GRANTED) + end + + def self.states_for(user_id) + where(user_id: user_id).pluck(:capability, :state).to_h + end + + def self.granted_count(user_id) + where(user_id: user_id, state: GRANTED).count + end + + def self.set!(user_id, capability, granted, via:) + state = granted ? GRANTED : WITHHELD + + transaction do + row = find_or_initialize_by(user_id: user_id, capability: capability) + row.state = state + row.changed_at = Time.current + row.changed_via = via + row.first_granted_at ||= row.changed_at if granted + row.save! + ConsentLog.create!(user_id: user_id, capability: capability, state: state, via: via) + row + end + end + end +end diff --git a/web/app/models/api/consent_log.rb b/web/app/models/api/consent_log.rb new file mode 100644 index 00000000..da849962 --- /dev/null +++ b/web/app/models/api/consent_log.rb @@ -0,0 +1,5 @@ +module Api + class ConsentLog < ApplicationRecord + self.table_name = "api.consent_log" + end +end diff --git a/web/app/models/api/event.rb b/web/app/models/api/event.rb new file mode 100644 index 00000000..3d1171e9 --- /dev/null +++ b/web/app/models/api/event.rb @@ -0,0 +1,9 @@ +module Api + class Event < ApplicationRecord + self.table_name = "api.event_log" + + def self.record!(verb, actor:, subject: nil, detail: nil) + create!(verb: verb, actor_user_id: actor, subject: subject, detail: detail) + end + end +end diff --git a/web/app/models/api/request_log.rb b/web/app/models/api/request_log.rb new file mode 100644 index 00000000..2f67e1fb --- /dev/null +++ b/web/app/models/api/request_log.rb @@ -0,0 +1,19 @@ +module Api + class RequestLog < ApplicationRecord + self.table_name = "api.request_log" + + MANAGER = "manager".freeze + NOT_MANAGER = "not_manager".freeze + WITHHELD = "withheld".freeze + + def self.log!(token_id, channel_id, outcomes) + return if outcomes.empty? + + now = Time.current + insert_all(outcomes.map do |user_id, outcome| + { token_id: token_id, channel_id: channel_id, subject_user_id: user_id, + outcome: outcome, at: now } + end) + end + end +end diff --git a/web/app/models/api/setting.rb b/web/app/models/api/setting.rb new file mode 100644 index 00000000..8360763a --- /dev/null +++ b/web/app/models/api/setting.rb @@ -0,0 +1,23 @@ +module Api + class Setting < ApplicationRecord + self.table_name = "api.setting" + self.primary_key = "key" + + DEFAULTS = { + "rate_per_minute" => 20, + "tokens_per_owner" => 3 + }.freeze + + def self.value(key) + DEFAULTS.fetch(key.to_s) + find_by(key: key.to_s)&.value || DEFAULTS.fetch(key.to_s) + end + + def self.set!(key, value, by:) + DEFAULTS.fetch(key.to_s) + row = find_or_initialize_by(key: key.to_s) + row.update!(value: value, changed_by: by, changed_at: Time.current) + row + end + end +end diff --git a/web/app/models/api/token.rb b/web/app/models/api/token.rb new file mode 100644 index 00000000..61cccef9 --- /dev/null +++ b/web/app/models/api/token.rb @@ -0,0 +1,103 @@ +require "securerandom" + +module Api + class Token < ApplicationRecord + self.table_name = "api.token" + + LEAD = "nemo_live_".freeze + ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".chars.freeze + LENGTH = 20 + SHOWN = 4 + MAX_NAME = 60 + + class TooMany < StandardError; end + + LIVES = { + "30" => 30.days, + "90" => 90.days, + "365" => 365.days, + "never" => nil + }.freeze + + LIFE_WORDS = { + "30" => "30 days", + "90" => "90 days", + "365" => "a year", + "never" => "no expiry" + }.freeze + + DEFAULT_LIFE = "90".freeze + + scope :live, lambda { + where(revoked_at: nil).where("expires_at IS NULL OR expires_at > now()") + } + + def self.for_owner(user_id) + where(owner_user_id: user_id).order(revoked_at: :asc, created_at: :desc) + end + + def self.room_for?(user_id) + live.where(owner_user_id: user_id).count < Setting.value("tokens_per_owner") + end + + def self.digest_of(secret) + Digest::SHA256.hexdigest(secret.to_s) + end + + def self.secret + LEAD + Array.new(LENGTH) { ALPHABET[SecureRandom.random_number(ALPHABET.size)] }.join + end + + def self.life_for(asked) + LIVES.key?(asked.to_s) ? asked.to_s : DEFAULT_LIFE + end + + def self.dies_on(asked) + span = LIVES.fetch(life_for(asked)) + span && span.from_now + end + + def self.mint!(owner_user_id, name, lasting: DEFAULT_LIFE) + raise TooMany unless room_for?(owner_user_id) + + key = secret + row = create!(owner_user_id: owner_user_id, name: name.to_s.strip.first(MAX_NAME), + prefix: key.first(LEAD.length + SHOWN), digest: digest_of(key), + expires_at: dies_on(lasting)) + [row, key] + end + + TOUCH_EVERY = 1.minute + + def revoked? = revoked_at.present? + + def expired? = expires_at.present? && expires_at <= Time.current + + def spent? = revoked? || expired? + + def used! + return if last_used_at && last_used_at > TOUCH_EVERY.ago + + update_column(:last_used_at, Time.current) + end + + def revoke!(by:) + update!(revoked_at: Time.current, revoked_by: by) + end + + def rate + rate_limit || Setting.value("rate_per_minute") + end + + def shown + "#{prefix}…" + end + + def dies_in + return "never" if expires_at.nil? + return "expired" if expired? + + "#{((expires_at - Time.current) / 1.day).ceil}d" + end + end +end diff --git a/web/app/models/docs.rb b/web/app/models/docs.rb new file mode 100644 index 00000000..5888ecfe --- /dev/null +++ b/web/app/models/docs.rb @@ -0,0 +1,24 @@ +class Docs + Section = Struct.new(:id, :title, keyword_init: true) + Topic = Struct.new(:slug, :title, :blurb, :sections, keyword_init: true) + + CHANNEL_MANAGERS = Topic.new( + slug: "channel-managers", + title: "Channel manager API", + blurb: "Resolves whether a member holds the channel manager role on a channel, public or " \ + "private. Gated on that member's consent. Returns nothing else about them.", + sections: [ + Section.new(id: "auth", title: "Authentication"), + Section.new(id: "check", title: "Check a member"), + Section.new(id: "consent", title: "Consent states"), + Section.new(id: "rate", title: "Rate limits"), + Section.new(id: "errors", title: "Errors") + ] + ) + + ALL = [CHANNEL_MANAGERS].freeze + + def self.topics = ALL + + def self.section_ids = ALL.flat_map { |topic| topic.sections.map(&:id) } +end diff --git a/web/app/models/fd/deeds.rb b/web/app/models/fd/deeds.rb index 7ecadb7c..f2907983 100644 --- a/web/app/models/fd/deeds.rb +++ b/web/app/models/fd/deeds.rb @@ -3,14 +3,35 @@ class Deeds LIMIT = 40 READ = "identity.read".freeze + VIEWS = { + "all" => { tab: "Everything", head: "Everything anyone did" }, + "audit" => { tab: "Firefighters", head: "Everything firefighters did" }, + "read" => { tab: "Identity reads", head: "Every identity read" }, + "api" => { tab: "API", head: "Everything anyone did to the API" } + }.freeze + + VIA = { "dashboard" => "from the dashboard", "command" => "from Slack" }.freeze + + KIND_VIEWS = { + "audit" => "audit", "read" => "read", "consent" => "api", "event" => "api", + "request" => "api" + }.freeze + Row = Struct.new(:at, :event, :kind, :id, :about, :who, :said, :actor, keyword_init: true) ON_CASE = %w[case participant assignee thread citation].freeze - def initialize(user_id, since:, only: nil, limit: LIMIT, offset: 0) + def self.view_for(asked) + VIEWS.key?(asked.to_s) ? asked.to_s : "all" + end + + attr_reader :view + + def initialize(user_id, since:, only: nil, view: nil, limit: LIMIT, offset: 0) @user_id = user_id @since = since @only = only + @view = self.class.view_for(view) @limit = limit @offset = offset end @@ -23,6 +44,10 @@ def total @total ||= picked("count(*) AS found").first["found"].to_i end + def totals + @totals ||= tallied + end + def member_ids rows.flat_map { |row| [row.kind == "member" ? row.id : row.who, row.actor] }.compact.uniq end @@ -33,7 +58,7 @@ def picked(select = "kind, id, at") return AuditEntry.connection.select_all("SELECT 0 AS found WHERE false") if nothing_asked? sql = <<~SQL - WITH picked AS (#{audit_side}#{reads_side}) + WITH picked AS (#{union}) SELECT #{select} FROM picked SQL sql += "ORDER BY at DESC LIMIT :limit OFFSET :offset" unless select.start_with?("count") @@ -43,11 +68,38 @@ def picked(select = "kind, id, at") ) end + def union + "#{audit_side}#{reads_side}#{consent_side}#{event_side}#{request_side}" + end + def nothing_asked? - audit_side.strip.empty? && reads_side.strip.empty? + union.strip.empty? + end + + def wanted?(name) + @view == "all" || @view == name + end + + def tallied + counted = VIEWS.keys.excluding("all").index_with(0) + return counted.merge("all" => 0) if nothing_asked? + + sql = <<~SQL + WITH picked AS (#{union}) + SELECT kind, count(*) AS found FROM picked GROUP BY kind + SQL + found = AuditEntry.connection.select_all( + AuditEntry.sanitize_sql([sql, { since: @since, who: @user_id }]) + ) + found.each do |row| + view = KIND_VIEWS.fetch(row["kind"]) + counted[view] += row["found"].to_i + end + counted.merge("all" => counted.values.sum) end def audit_side + return "" unless wanted?("audit") return "" if @only && @only != READ && Permission.events(@only).empty? return "" if @only == READ @@ -60,18 +112,63 @@ def audit_side end def reads_side + return "" unless wanted?("read") return "" if @only && @only != READ mine = @user_id ? "AND l.actor_id = :who" : "" - lead = audit_side.empty? ? "" : "UNION ALL" <<~SQL - #{lead} + #{audit_side.empty? ? '' : 'UNION ALL'} SELECT 'read' AS kind, l.id AS id, l.looked_at AS at FROM access_log l WHERE l.looked_at >= :since AND l.field_class = 'identity' #{mine} SQL end + def consent_side + return "" unless wanted?("api") + return "" if @only + + mine = @user_id ? "AND c.user_id = :who" : "" + <<~SQL + #{lead_for(audit_side, reads_side)} + SELECT 'consent' AS kind, c.id AS id, c.at AS at + FROM api.consent_log c + WHERE c.at >= :since #{mine} + SQL + end + + def event_side + return "" unless wanted?("api") + return "" if @only + + mine = @user_id ? "AND e.actor_user_id = :who" : "" + <<~SQL + #{lead_for(audit_side, reads_side, consent_side)} + SELECT 'event' AS kind, e.id AS id, e.at AS at + FROM api.event_log e + WHERE e.at >= :since #{mine} + SQL + end + + def request_side + return "" unless wanted?("api") + return "" if @only + + mine = @user_id ? "AND t.owner_user_id = :who" : "" + <<~SQL + #{lead_for(audit_side, reads_side, consent_side, event_side)} + SELECT 'request' AS kind, r.token_id AS id, date_trunc('day', r.at) AS at + FROM api.request_log r + JOIN api.token t ON t.id = r.token_id + WHERE r.at >= :since #{mine} + GROUP BY r.token_id, date_trunc('day', r.at) + SQL + end + + def lead_for(*sides) + sides.all?(&:empty?) ? "" : "UNION ALL" + end + def only_clause return "" unless @only @@ -99,6 +196,9 @@ def built chosen = picked.to_a @picked_ids = chosen.select { |row| row["kind"] == "audit" }.map { |row| row["id"] } read_rows = reads_for(chosen.select { |row| row["kind"] == "read" }.map { |row| row["id"] }) + consent_rows = consents_for(ids_of(chosen, "consent")) + event_rows = events_for(ids_of(chosen, "event")) + request_rows = requests_for(chosen.select { |row| row["kind"] == "request" }) found = entries @actions = Action.where(id: ids(found, "action")).index_by(&:id) @@ -109,7 +209,72 @@ def built .pluck(:id, :title).to_h made = found.map { |row| row_for(row).tap { |one| one.actor = row.actor_user_id } } - (made + read_rows).sort_by { |row| -row.at.to_i } + (made + read_rows + consent_rows + event_rows + request_rows) + .sort_by { |row| -row.at.to_i } + end + + def ids_of(chosen, kind) + chosen.select { |row| row["kind"] == kind }.map { |row| row["id"] } + end + + ROLLED = <<~SQL.freeze + SELECT r.token_id, date_trunc('day', r.at) AS day, t.name AS token_name, + t.owner_user_id AS owner, count(*) AS asks, + count(*) FILTER (WHERE r.outcome = 'withheld') AS withheld, + count(DISTINCT r.subject_user_id) AS people, + count(DISTINCT r.channel_id) AS rooms + FROM api.request_log r + JOIN api.token t ON t.id = r.token_id + WHERE r.at >= :since + GROUP BY r.token_id, date_trunc('day', r.at), t.name, t.owner_user_id + SQL + + def requests_for(picked_rows) + return [] if picked_rows.empty? + + wanted = picked_rows.map { |row| [row["id"].to_i, row["at"].to_time.to_i] }.to_set + rolled.filter_map do |row| + next unless wanted.include?([row["token_id"].to_i, row["day"].to_time.to_i]) + + request_row(row) + end + end + + def rolled + AuditEntry.connection.select_all( + AuditEntry.sanitize_sql([ROLLED, { since: @since }]) + ) + end + + def request_row(row) + people = row["people"].to_i + withheld = row["withheld"].to_i + rooms = row["rooms"].to_i + said = ["#{rooms} #{'channel'.pluralize(rooms)}"] + said << "#{withheld} withheld" if withheld.positive? + + Row.new(at: row["day"], event: "api/checked", kind: "capability", + about: "#{people} #{'member'.pluralize(people)}", actor: row["owner"], + said: ([row["token_name"]] + said).join(" · ")) + end + + def events_for(ids) + return [] if ids.empty? + + ::Api::Event.where(id: ids).map do |event| + Row.new(at: event.at, event: "api/#{event.verb}", kind: "capability", + about: event.subject, actor: event.actor_user_id, said: event.detail) + end + end + + def consents_for(ids) + return [] if ids.empty? + + ::Api::ConsentLog.where(id: ids).map do |log| + Row.new(at: log.at, event: "consent/#{log.state}", kind: "capability", + about: ::Api::Capability.said(log.capability), actor: log.user_id, + said: VIA.fetch(log.via, log.via)) + end end def reads_for(ids) diff --git a/web/app/models/fd/flag.rb b/web/app/models/fd/flag.rb index 73298a8e..5e72b432 100644 --- a/web/app/models/fd/flag.rb +++ b/web/app/models/fd/flag.rb @@ -15,6 +15,8 @@ def self.label(key) = fetch(key).fetch("label") def self.covers(key) = fetch(key).fetch("covers") + def self.audience(key) = fetch(key)["audience"] + def self.default?(key) = fetch(key).fetch("default") == true def self.flipped diff --git a/web/app/services/channel_managers.rb b/web/app/services/channel_managers.rb new file mode 100644 index 00000000..ad4ae39a --- /dev/null +++ b/web/app/services/channel_managers.rb @@ -0,0 +1,87 @@ +class ChannelManagers + TTL = 1.hour + HELD_FOR = 20.seconds + METHOD = "admin.roles.listAssignments".freeze + PAGE = 200 + PAGE_CAP = 20 + + def self.role_id + ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"].presence + end + + def self.for(channel_id) + return [] if channel_id.blank? + + freshen(channel_id) + Api::ChannelManager.user_ids_in(channel_id) + end + + def self.manages?(channel_id, user_id) + self.for(channel_id).include?(user_id) + end + + def self.swept_at(channel_id) + Api::ChannelSweep.find_by(channel_id: channel_id)&.synced_at + end + + def self.freshen(channel_id) + swept = Api::ChannelSweep.find_by(channel_id: channel_id) + + return refresh(channel_id) if swept.nil? + return unless swept.stale?(TTL) + + refresh(channel_id) if claim(channel_id) + end + + def self.claim(channel_id) + Rails.cache.write("channel_managers/lock/#{channel_id}", true, + expires_in: HELD_FOR, unless_exist: true) + end + + def self.refresh(channel_id) + found = fetch(channel_id) + return if found.nil? + + store(channel_id, found) + end + + def self.fetch(channel_id) + return nil if role_id.nil? + + found = [] + cursor = nil + PAGE_CAP.times do + page = Slack::ProxyClient.call(METHOD, + { role_ids: role_id, entity_ids: channel_id, limit: PAGE, cursor: cursor }, + credential: "admin") + return nil unless page["ok"] + + Array(page["role_assignments"]).each do |row| + next unless row["entity_id"] == channel_id && row["user_id"].present? + + found << [row["user_id"], row["date_create"]] + end + + cursor = page.dig("response_metadata", "next_cursor").presence + break if cursor.nil? + end + found + rescue Slack::ProxyClient::Error, Slack::ProxyClient::NotConfigured => e + Rails.logger.warn("channel_managers: #{e.class} for #{channel_id}") + nil + end + + def self.store(channel_id, found) + Api::ChannelManager.transaction do + Api::ChannelManager.where(channel_id: channel_id).delete_all + Api::ChannelManager.insert_all(rows_for(channel_id, found)) if found.any? + Api::ChannelSweep.stamp!(channel_id, found.size) + end + end + + def self.rows_for(channel_id, found) + found.uniq { |user_id, _at| user_id }.map do |user_id, at| + { channel_id: channel_id, user_id: user_id, assigned_at: at ? Time.zone.at(at) : nil } + end + end +end diff --git a/web/app/views/docs/_channel_managers.html.erb b/web/app/views/docs/_channel_managers.html.erb new file mode 100644 index 00000000..2dc68615 --- /dev/null +++ b/web/app/views/docs/_channel_managers.html.erb @@ -0,0 +1,159 @@ +<% channel_pattern = Api::V1::ChannelManagersController::CHANNEL.source %> +<% member_pattern = Api::V1::ChannelManagersController::MEMBER.source %> + +
+

<%= topic.title %>

+

<%= topic.blurb %>

+
+
Base
<%= request.base_url %>/api/v1
+
Scheme
Bearer
+
Rate
<%= @rate %>/min per key
+
Format
application/json
+
+
+ +
+

Authentication

+
Authorization: Bearer nemo_live_7Fj2QcW8xR4mLpVdA1sT
+

+ Keys are nemo_live_ plus 20 base58 characters, minted on + <%= link_to "your settings page", you_api_path(tab: "tokens") %> and stored as a SHA-256 + digest. Shown once. Never accepted as a query parameter. +

+
GET /api/v1/token // what this key is
+
+{ "name": "Toolbox", "prefix": "nemo_live_7Fj2",
+  "owner_user_id": "U0A5PLKMB25", "rate_per_minute": <%= @rate %> }
+
+ +
+

GET Check a member

+
/api/v1/channels/{channel_id}/managers/{user_id}
+

One channel and one member per request. There is no batch form.

+
+ + + + + + + + + + + + + + + + +
parampatternnotes
channel_id<%= channel_pattern %>Public or private. Never validated against an index.
user_id<%= member_pattern %>Unknown ids answer withheld.
+
+
+
+

200 · consent granted

+
{
+  "channel_id": "C0P5NE3M0",
+  "user_id": "U0A5PLKMB25",
+  "consent": "granted",
+  "is_manager": true,
+  "synced_at": "2026-08-31T04:12:07Z",
+  "stale": false
+}
+
+
+

200 · consent withheld

+
{
+  "channel_id": "C0P5NE3M0",
+  "user_id": "U0A5PLKMB25",
+  "consent": "withheld",
+  "is_manager": null,
+  "opt_in_url": "<%= you_api_url %>"
+}
+
+
+
+ + + + + + + + + + + + + + + + +
fieldtypenotes
consentstringgranted or withheld. Always present.
is_managerbool | nullnull whenever consent is withheld.
synced_atiso8601?When the channel was last read from Slack. Absent when withheld.
stalebool?true past the cache TTL. Answer still served.
opt_in_urlstring?Present only when withheld.
+
+
+ + + +
+

Rate limits

+

+ <%= @rate %> requests per fixed 60 second window, per key. Overridable per key + by a community manager. +

+
RateLimit-Limit: <%= @rate %>
+RateLimit-Remaining: <%= [@rate - 3, 0].max %>
+RateLimit-Reset: 34        // seconds until the window rolls
+Retry-After: 34            // 429 only, matches body.retry_after
+

+ The first three are on every answered request. Refusals raised before the key is resolved + carry none, since there is no budget to report. +

+
+ +
+

Errors

+
{ "error": "rate_limited", "message": "rate limit spent, see retry_after", "retry_after": 34 }
+
+ + + + + + <% Api::V1::BaseController::CALLER_ERRORS.each do |status, key, said| %> + + + + + + <% end %> + +
statuserrorcause
<%= status %><%= key %><%= said %>
+
+
diff --git a/web/app/views/docs/show.html.erb b/web/app/views/docs/show.html.erb new file mode 100644 index 00000000..99f514cd --- /dev/null +++ b/web/app/views/docs/show.html.erb @@ -0,0 +1,19 @@ +<% content_for :page_title, "Docs" %> +<% content_for :head_actions do %> + <%= link_to "Your tokens", you_api_path(tab: "tokens"), class: "btn" %> +<% end %> + +
+ + +
+ <%= render "docs/channel_managers", topic: Docs::CHANNEL_MANAGERS %> +
+
diff --git a/web/app/views/fd/audits/show.html.erb b/web/app/views/fd/audits/show.html.erb index 707f3fe0..972dc8eb 100644 --- a/web/app/views/fd/audits/show.html.erb +++ b/web/app/views/fd/audits/show.html.erb @@ -1,8 +1,18 @@ <% content_for :page_title, "Audit log" %>
+
+ <% Fd::Deeds::VIEWS.each do |key, labels| %> + <%= link_to fd_audit_path(key == "all" ? {} : { view: key }), class: "view", + aria: { current: ("true" if key == @view) } do %> + <%= labels[:tab] %> + <%= number_with_delimiter(@counts.fetch(key, 0)) %> + <% end %> + <% end %> +
+
- Everything firefighters did + <%= Fd::Deeds::VIEWS.fetch(@view)[:head] %> last 30 days · <%= pluralize(@total, "entry") %> @@ -46,13 +56,13 @@ <% if @pages > 1 %>
<% if @page > 1 %> - <%= link_to "Back", fd_audit_path(page: @page - 1), class: "btn" %> + <%= link_to "Back", fd_audit_path(page: @page - 1, view: @seen), class: "btn" %> <% else %> Back <% end %> Page <%= @page %> of <%= @pages %> <% if @page < @pages %> - <%= link_to "Next", fd_audit_path(page: @page + 1), class: "btn" %> + <%= link_to "Next", fd_audit_path(page: @page + 1, view: @seen), class: "btn" %> <% else %> Next <% end %> diff --git a/web/app/views/fd/settings/_api_modals.html.erb b/web/app/views/fd/settings/_api_modals.html.erb new file mode 100644 index 00000000..f96e077a --- /dev/null +++ b/web/app/views/fd/settings/_api_modals.html.erb @@ -0,0 +1,65 @@ +<% @dials.each do |key, value| %> + <%= render layout: "fd/modal", locals: { id: "dial-#{key}", title: dial_label(key), + sub: "in force now: #{value}" } do %> + <%= form_with url: fd_api_setting_path, method: :patch, html: { autocomplete: "off" } do %> + <%= hidden_field_tag :key, key, id: nil %> + + + <% end %> + <% end %> +<% end %> + +<% @tokens.reject(&:spent?).each do |token| %> + <%= render layout: "fd/modal", locals: { id: "rate-#{token.id}", + title: "Rate for #{token.name}", sub: "owned by #{names[token.owner_user_id]}" } do %> + <%= form_with url: fd_api_token_rate_path(token), method: :patch, + html: { autocomplete: "off" } do %> + + + <% end %> + <% end %> +<% end %> diff --git a/web/app/views/fd/settings/show.html.erb b/web/app/views/fd/settings/show.html.erb index 74d699d5..2342ca74 100644 --- a/web/app/views/fd/settings/show.html.erb +++ b/web/app/views/fd/settings/show.html.erb @@ -90,6 +90,126 @@
+ <% elsif @tab == "api" %> +
+
+
+

Opted in

+

<%= number_with_delimiter(@opted_in) %>

+

<%= pluralize(Api::Capability::KEYS.size, "capability") %>

+
+
+

Live tokens

+

<%= @tokens.count { |one| !one.spent? } %>

+

across <%= pluralize(@tokens.map(&:owner_user_id).uniq.size, "owner") %>

+
+
+

Checks, 30 days

+

<%= number_with_delimiter(@checks) %>

+

<%= pluralize(@channels, "channel") %>

+
+
+

Withheld

+

<%= number_with_delimiter(@withheld) %>

+

<%= withheld_share(@withheld, @checks) %>

+
+
+ +
+
+ How it runs + + <%= api_state_chip %> + <%= flag_switch(:public_api) %> + +
+
+ <% @dials.each do |key, value| %> +
+ <%= dial_label(key) %> + + <%= value %> + + +
+ <% end %> +
+ Manager data last read + "><%= synced_line(@synced) %> +
+
+ Last changed + "><%= dial_change(@last_dial) %> +
+
+
+
+ +
+ Every token + + + <%= @tokens.count { |one| !one.spent? } %> live · + <%= @tokens.count(&:spent?) %> spent + +
+ + <% if @tokens.any? %> +
+ + + + + + + + + + + + + + + <% @tokens.each do |token| %> + + + + + + + + + + + <% end %> + +
ownernamekeyrateexpiresasks 30dlast used
+ + <%= face(token.owner_user_id) %> + <%= names[token.owner_user_id] %> + + "><%= token.name %><%= token.shown %>"> + <%= token.revoked? ? "n/a" : token.rate %> + <%= token.spent? ? "n/a" : token.dies_in %><%= number_with_delimiter(@asks[token.id].to_i) %> + <%= token.last_used_at ? time_ago_in_words(token.last_used_at) : "never" %> + + <% if token.revoked? %> + + Revoked <%= token.revoked_at.strftime("%-d %b") %> + + <% else %> + + <% end %> +
+
+ <% else %> +
+

Nobody holds a token

+
+ <% end %> + <% elsif @tab == "sections" %>
@@ -107,7 +227,9 @@ - + <% end %> @@ -504,6 +626,9 @@ <% if current_staff.may?("access.grant") %> + <% if @tab == "api" %> + <%= render "fd/settings/api_modals" %> + <% end %> <%= render "fd/settings/give_modal" %> <% if @person %> <%= render "fd/settings/change_modal", grant: @person %> diff --git a/web/app/views/layouts/application.html.erb b/web/app/views/layouts/application.html.erb index 76355349..007ede10 100644 --- a/web/app/views/layouts/application.html.erb +++ b/web/app/views/layouts/application.html.erb @@ -41,7 +41,8 @@ <%= render "shared/synthetic_banner" %> - <% fire_engine = on?(:fire_engine) %> + <% staffer = current_staff&.role.present? %> + <% fire_engine = staffer && on?(:fire_engine) %>
" data-palette-url-value="<%= fd_search_path(format: :json) %>" data-palette-on-value="<%= content_for(:palette_on) %>" @@ -52,7 +53,7 @@ Mnemosyne
- <% if on?(:analytics) %> + <% if staffer && on?(:analytics) %> <% end %> - <% if fire_engine %> -
- <% if current_staff&.may?("access.read") %> + + +
+ <% if fire_engine && current_staff&.may?("access.read") %> <%= link_to fd_audit_path, class: "rail-item", aria: { current: ("page" if controller_path == "fd/audits") } do %>
- <% end %> + <% end %> +
diff --git a/web/app/views/sessions/failure.html.erb b/web/app/views/sessions/failure.html.erb index 793ea193..2dd9824b 100644 --- a/web/app/views/sessions/failure.html.erb +++ b/web/app/views/sessions/failure.html.erb @@ -1,14 +1,15 @@

Access denied

<% if @message.in?(["not_allowlisted", "no_access"]) %> -

You are not allowlisted. Ask a community manager to grant you a role.

+

You are not allowlisted for the dashboard. Ask a community manager for a role.

<% else %>

Something went wrong signing in.

<%= @message %>

<% end %> <% if current_staff %> - <%= button_to "Sign out", logout_path, method: :delete, class: "btn w-full" %> + <%= link_to "Go to your API settings", you_api_path, class: "btn btn-primary w-full" %> + <%= button_to "Sign out", logout_path, method: :delete, class: "btn w-full mt-2" %> <% else %> <%= link_to "Back to sign in", login_path, class: "btn w-full" %> <% end %> diff --git a/web/app/views/you/api/_kill.html.erb b/web/app/views/you/api/_kill.html.erb new file mode 100644 index 00000000..2c90538f --- /dev/null +++ b/web/app/views/you/api/_kill.html.erb @@ -0,0 +1,27 @@ +<%= render layout: "fd/modal", locals: { id: "kill-#{token.id}", + title: "Revoke #{token.name}?" } do %> + <%= form_with url: you_token_path(token), method: :delete do %> + + + <% end %> +<% end %> diff --git a/web/app/views/you/api/_mint.html.erb b/web/app/views/you/api/_mint.html.erb new file mode 100644 index 00000000..e7868924 --- /dev/null +++ b/web/app/views/you/api/_mint.html.erb @@ -0,0 +1,47 @@ +<%= render layout: "fd/modal", locals: { id: "mint-token", + title: @secret.present? ? "Copy your key" : "New API key", + open: @secret.present? } do %> + <% if @secret.present? %> + + + <% else %> + <%= form_with url: you_tokens_path, method: :post, + data: { turbo: false }, html: { autocomplete: "off" } do %> + + + <% end %> + <% end %> +<% end %> diff --git a/web/app/views/you/api/show.html.erb b/web/app/views/you/api/show.html.erb new file mode 100644 index 00000000..161b92d5 --- /dev/null +++ b/web/app/views/you/api/show.html.erb @@ -0,0 +1,151 @@ +<% content_for :page_title, "API access" %> +<% api_off = Fd::Flag.off?(:public_api) %> +<% granted = Api::Capability::KEYS.count { |key| @consents[key] == Api::Consent::GRANTED } %> +<% live = @tokens.reject(&:spent?) %> +<% cap = Api::Setting.value("tokens_per_owner") %> + +
+
+
+
+ <% You::ApiController::TABS.each do |key, label| %> + <%= link_to you_api_path(key == "permissions" ? {} : { tab: key }), class: "view", + aria: { current: ("true" if key == @tab) } do %> + <%= label %> + + <%= key == "permissions" ? Api::Capability::KEYS.size : live.size %> + + <% end %> + <% end %> +
+ + <% if @tab == "permissions" %> +
+ <% Api::Capability::KEYS.each do |key| %> + <% on = @consents[key] == Api::Consent::GRANTED %> +
+ + <%= Api::Capability.label(key) %> + <%= Api::Capability.covers(key) %> + + + <%= button_to on ? "Opt out" : "Opt in", + you_consent_path(capability: key, on: on ? "0" : "1"), + method: :patch, disabled: api_off, + class: "btn #{'btn-primary' unless on} #{'is-off' if api_off}", + form: { class: "contents" } %> + +
+ <% end %> +
+ + <% else %> +
+ Keys you hold + + <%= live.size %> of <%= cap %> + <% if live.size < cap && !api_off %> + + <% end %> +
+ + <% if @tokens.any? %> +
+
<%= Fd::Flag.label(key) %> <%= Fd::Flag.covers(key) %><%= pluralize(holders, "person") %> + <%= Fd::Flag.audience(key) || pluralize(holders, "person") %> + <%= flag_switch(key) %>
+ + + + + + + + + + + + <% @tokens.each do |token| %> + + + + + + + + + <% end %> + +
namekeyrateexpireslast used
"><%= token.name %><%= token.shown %> + <%= token.revoked? ? "n/a" : "#{token.rate}/min" %> + "> + <%= token.spent? ? "n/a" : token.dies_in %> + + <%= token.last_used_at ? time_ago_in_words(token.last_used_at) : "never" %> + + <% if token.revoked? %> + + Revoked <%= token.revoked_at.strftime("%-d %b") %> + + <% elsif token.expired? %> + + Expired <%= token.expires_at.strftime("%-d %b") %> + + <% else %> + + <% end %> +
+
+ <% else %> +
+

No tokens

+

Opting in does not need one.

+
+ <% end %> + <% end %> +
+
+ +
+

Your account

+ +
+
+ <% if current_profile&.image_url %> + + <% else %> + <%= member_id.first %> + <% end %> + + <%= current_profile&.display_name.presence || member_id %> + <%= member_id %> + +
+ +
+
+ Opted in to + "> + <%= granted.zero? ? "nothing" : "#{granted} of #{Api::Capability::KEYS.size}" %> + +
+
+ Live tokens + "><%= live.size %> of <%= cap %> +
+
+ Channels you are in + "> + <%= @rooms.any? ? number_with_delimiter(@rooms.size) : "n/a" %> + +
+
+
+
+ + +<% if @tab == "tokens" && !api_off %> + <%= render "you/api/mint" %> + <% live.each do |token| %> + <%= render "you/api/kill", token: token %> + <% end %> +<% end %> diff --git a/web/config/routes.rb b/web/config/routes.rb index 58886d15..f9f2205b 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -10,6 +10,22 @@ get "dev/be/:user_id", to: "dev_sessions#create", as: :dev_be end + namespace :api do + namespace :v1 do + resource :token, only: [:show], controller: "tokens" + get "channels/:channel_id/managers/:user_id", to: "channel_managers#show", + as: :channel_manager + end + end + + get "docs", to: "docs#show", as: :docs + + namespace :you do + get "api", to: "api#show", as: :api + resource :consent, only: [:update], controller: "consents" + resources :tokens, only: [:create, :destroy] + end + namespace :fd do root to: "fire#show" post "cases/merge", to: "merges#create", as: :merge_cases @@ -21,6 +37,9 @@ end resource :search, only: [:show], controller: "searches" resource :settings, only: [:show] + patch "api_setting", to: "api_settings#update", as: :api_setting + patch "api_tokens/:id/rate", to: "api_settings#rate", as: :api_token_rate + delete "api_tokens/:id", to: "api_settings#destroy", as: :api_token get "audit", to: "audits#show", as: :audit get "slack_account/callback", to: "slack_accounts#callback", as: :slack_account_callback resource :slack_account, only: [:create, :destroy], controller: "slack_accounts" diff --git a/web/test/controllers/home_controller_test.rb b/web/test/controllers/home_controller_test.rb index 809b7bf1..5b4132db 100644 --- a/web/test/controllers/home_controller_test.rb +++ b/web/test/controllers/home_controller_test.rb @@ -22,12 +22,12 @@ class HomeControllerTest < ActionDispatch::IntegrationTest assert_redirected_to login_path end - test "a staff row with no roles cannot sign in, so it never reaches the dashboard" do + test "a staff row with no roles signs in, and still never reaches the dashboard" do staff = Staff.create!(user_id: "UTESTNONE1") sign_in_as(staff) - assert_redirected_to auth_failure_path(message: "not_allowlisted") + assert_redirected_to you_api_path get root_path - assert_redirected_to login_path + assert_redirected_to auth_failure_path(message: "not_allowlisted") end end diff --git a/web/test/controllers/sessions_controller_test.rb b/web/test/controllers/sessions_controller_test.rb index 353e3113..d17689e8 100644 --- a/web/test/controllers/sessions_controller_test.rb +++ b/web/test/controllers/sessions_controller_test.rb @@ -20,23 +20,23 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_equal "UTESTALLOWED", session[:user_id] end - test "a staff row with no grant is rejected like a stranger" do + test "a staff row with no grant gets the member area, not the dashboard" do Staff.create!(user_id: "UTESTNOGRANT", community_manager: false) mock_hca_auth("UTESTNOGRANT") get "/auth/hackclub/callback" - assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] + assert_redirected_to you_api_path + assert_equal "UTESTNOGRANT", session[:user_id] end - test "unknown slack id is rejected" do + test "unknown slack id gets the member area too" do mock_hca_auth("UNOTALLOWED") get "/auth/hackclub/callback" - assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] + assert_redirected_to you_api_path + assert_equal "UNOTALLOWED", session[:user_id] end test "missing slack_id claim is rejected" do @@ -49,7 +49,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest get "/auth/hackclub/callback" - assert_redirected_to auth_failure_path(message: "not_allowlisted") + assert_redirected_to auth_failure_path(message: "no_slack_id") + assert_nil session[:user_id] end test "logout clears the session" do diff --git a/web/test/integration/api_auth_test.rb b/web/test/integration/api_auth_test.rb new file mode 100644 index 00000000..285fb03e --- /dev/null +++ b/web/test/integration/api_auth_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class ApiAuthTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox") + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def ask(key = @secret, path: api_v1_token_path) + get path, headers: key ? { "Authorization" => "Bearer #{key}" } : {} + end + + def body + JSON.parse(response.body) + end + + test "a live key is told who it is, and never what it is" do + ask + + assert_response :success + assert_equal ["Toolbox", "UOWNER1", 20], body.values_at("name", "owner_user_id", + "rate_per_minute") + assert_equal @token.prefix, body["prefix"] + assert_no_match(/#{@secret}/, response.body, "the key itself must never come back") + end + + test "no header at all is an invalid token, not a crash" do + ask(nil) + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "a key nobody minted is refused" do + ask("nemo_live_neverissuedatall12") + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "a header that is not a bearer is refused" do + get api_v1_token_path, headers: { "Authorization" => "Basic #{@secret}" } + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "a revoked key says so, so a caller stops retrying" do + @token.revoke!(by: "UOWNER1") + ask + + assert_response :unauthorized + assert_equal "revoked_token", body["error"] + end + + test "the whole api is shut while the flag is off, even with a good key" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + ask + + assert_response :service_unavailable + assert_equal "api_off", body["error"] + end + + test "using a key stamps it, and does not stamp it again on every call" do + assert_nil @token.last_used_at + + ask + first = @token.reload.last_used_at + assert_not_nil first + + ask + assert_equal first, @token.reload.last_used_at, "one write a minute, not one a request" + end + + test "a signed in browser session opens nothing on the api" do + staff = Staff.create!(user_id: "UBOSS2", community_manager: true) + sign_in_as(staff) + + get api_v1_token_path + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "every refusal answers in json, never a redirect or a page" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + ask(nil) + + assert_equal "application/json", response.media_type + assert body.key?("message"), "a caller is told what to do about it" + end +end diff --git a/web/test/integration/api_channel_managers_test.rb b/web/test/integration/api_channel_managers_test.rb new file mode 100644 index 00000000..1d744ee4 --- /dev/null +++ b/web/test/integration/api_channel_managers_test.rb @@ -0,0 +1,158 @@ +require "test_helper" + +class ApiChannelManagersTest < ActionDispatch::IntegrationTest + MANAGER = "U0BGRUHPTTR".freeze + BYSTANDER = "U04KX9TQ2AA".freeze + + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox") + @channel = Analytics::DimChannel.first.channel_id + Api::ChannelManager.delete_all + Api::ChannelSweep.delete_all + Api::RequestLog.delete_all + Api::Consent.delete_all + manages(MANAGER) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def manages(user_id, on: @channel) + Api::ChannelManager.create!(channel_id: on, user_id: user_id, assigned_at: 1.year.ago) + Api::ChannelSweep.stamp!(on, 1) + end + + def opted_in(user_id) + Api::Consent.set!(user_id, "channel_manager", true, via: "dashboard") + end + + def head(key = @secret) + { "Authorization" => "Bearer #{key}" } + end + + def ask(user_id, on: @channel) + get api_v1_channel_manager_path(channel_id: on, user_id: user_id), headers: head + end + + def body = JSON.parse(response.body) + + def body_of + yield + body + end + + test "an opted in manager is answered yes, with when and how fresh" do + opted_in(MANAGER) + ask(MANAGER) + + assert_response :success + assert_equal [@channel, MANAGER, "granted", true], body.values_at("channel_id", "user_id", + "consent", "is_manager") + assert_not body["stale"] + assert body["synced_at"].present? + assert_not body.key?("since"), "when somebody became a manager is not the caller's business" + end + + test "an opted in bystander is answered no" do + opted_in(BYSTANDER) + ask(BYSTANDER) + + assert_response :success + assert_equal ["granted", false], body.values_at("consent", "is_manager") + end + + test "somebody who never opted in is withheld, and told nothing else" do + ask(MANAGER) + + assert_response :success + assert_equal "withheld", body["consent"] + assert_nil body["is_manager"] + assert_match(%r{/you/api\z}, body["opt_in_url"]) + end + + test "withheld looks the same whether or not they manage it" do + ask(MANAGER) + managing = body + + ask(BYSTANDER) + + assert_equal managing.except("user_id"), body.except("user_id"), + "the shape must not leak the answer it is withholding" + end + + test "a withheld ask never carries freshness, because nothing was read" do + ask(MANAGER) + + assert_not body.key?("synced_at") + assert_not body.key?("stale") + end + + test "a private channel is answered like any other, because the member opted in" do + opted_in(MANAGER) + manages(MANAGER, on: "C0PRIVATE99") + ask(MANAGER, on: "C0PRIVATE99") + + assert_response :success + assert_equal ["granted", true], body.values_at("consent", "is_manager") + end + + test "a channel we hold nothing on answers no, not whether it exists" do + opted_in(MANAGER) + ask(MANAGER, on: "C0NOTHINGHERE") + + assert_response :success + assert_equal ["granted", false], body.values_at("consent", "is_manager") + end + + test "consent is settled before the channel, so an opted out ask reveals no channel" do + real = body_of { ask(MANAGER, on: @channel) } + made_up = body_of { ask(MANAGER, on: "C0NOSUCHTHING") } + + assert_equal "withheld", real["consent"] + assert_equal real.except("channel_id"), made_up.except("channel_id"), + "a private or unknown channel must look the same while consent is withheld" + end + + test "a malformed id is refused before anything is read" do + ask("nope") + assert_response :unprocessable_content + assert_equal "bad_user_id", body["error"] + + ask(MANAGER, on: "nope") + assert_response :unprocessable_content + assert_equal "bad_channel_id", body["error"] + assert_empty Api::RequestLog.all, "a malformed id is not an ask about anybody" + end + + test "every ask is written down, with what it was told" do + opted_in(MANAGER) + ask(MANAGER) + ask(BYSTANDER) + + logged = Api::RequestLog.order(:id).pluck(:subject_user_id, :outcome, :channel_id) + assert_equal [[MANAGER, "manager", @channel], [BYSTANDER, "withheld", @channel]], logged + assert_equal [@token.id], Api::RequestLog.distinct.pluck(:token_id) + end + + test "a withheld ask reads nothing about the channel" do + asked = [] + was = ChannelManagers.method(:freshen) + ChannelManagers.define_singleton_method(:freshen) { |id| asked << id } + ask(MANAGER) + + assert_empty asked, "consent is checked before slack is ever troubled" + assert_equal 1, Api::RequestLog.where(outcome: "withheld").count + ensure + ChannelManagers.define_singleton_method(:freshen, was) + end + + test "the api still needs a token for a check" do + get api_v1_channel_manager_path(channel_id: @channel, user_id: MANAGER) + + assert_response :unauthorized + assert_empty Api::RequestLog.all + end +end diff --git a/web/test/integration/api_docs_test.rb b/web/test/integration/api_docs_test.rb new file mode 100644 index 00000000..58a65499 --- /dev/null +++ b/web/test/integration/api_docs_test.rb @@ -0,0 +1,65 @@ +require "test_helper" + +class ApiDocsTest < ActionDispatch::IntegrationTest + setup do + @member = Staff.create!(user_id: "UMEMBER3") + end + + test "any signed in member can read the docs, role or no role" do + sign_in_as(@member) + get docs_path + + assert_response :success + assert_select ".docs-sec", Docs.section_ids.size + assert_select ".rail-item[href=?]", docs_path + end + + test "signed out, the docs ask you to sign in" do + get docs_path + + assert_redirected_to login_path + end + + test "the rate it quotes is the one actually in force" do + Api::Setting.set!("rate_per_minute", 45, by: "UBOSS") + sign_in_as(@member) + get docs_path + + assert_select ".docs-meta dd", text: "45/min per key" + assert_select ".pre", text: /RateLimit-Limit: 45/ + end + + test "every error the api can return is written down, and nothing else" do + sign_in_as(@member) + get docs_path + + listed = css_select("#errors .data-table tbody td:nth-child(2)").map(&:text) + assert_equal Api::V1::BaseController::CALLER_ERRORS.map { |_status, key, _said| key }.sort, + listed.sort + end + + test "the contents and the page cannot drift apart" do + sign_in_as(@member) + get docs_path + + listed = css_select(".docs-nav a").map { |link| link["href"].delete_prefix("#") } + rendered = css_select(".docs-sec").map { |sec| sec["id"] } + + assert_equal Docs.section_ids, listed, "the contents list every section, in order" + assert_equal Docs.section_ids, rendered, "and every one of them is on the page" + end + + test "a topic names its sections once, so a duplicate anchor cannot creep in" do + assert_equal Docs.section_ids.uniq, Docs.section_ids + end + + test "the docs never name a key somebody actually holds" do + sign_in_as(@member) + _token, secret = Api::Token.mint!(@member.user_id, "Toolbox") + get docs_path + + assert_no_match(/#{Regexp.escape(secret)}/, response.body, + "a real key must not leak into the examples") + assert_match(/nemo_live_7Fj2/, response.body, "the example key is a made up one") + end +end diff --git a/web/test/integration/api_rate_limit_test.rb b/web/test/integration/api_rate_limit_test.rb new file mode 100644 index 00000000..ca43efd1 --- /dev/null +++ b/web/test/integration/api_rate_limit_test.rb @@ -0,0 +1,110 @@ +require "test_helper" + +class ApiRateLimitTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox") + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def ask(key = @secret) + get api_v1_token_path, headers: { "Authorization" => "Bearer #{key}" } + end + + def budget + response.headers.slice("RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset") + end + + def body = JSON.parse(response.body) + + test "the default budget is twenty a minute" do + assert_equal 20, Api::Setting.value("rate_per_minute") + assert_equal 20, @token.rate + end + + test "every answer says how much budget is left" do + with_a_real_cache do + ask + + assert_response :success + assert_equal "20", budget["RateLimit-Limit"] + assert_equal "19", budget["RateLimit-Remaining"] + assert budget["RateLimit-Reset"].to_i.between?(1, 60) + end + end + + test "the remaining budget counts down, and stops at nought" do + with_a_real_cache do + 3.times { ask } + assert_equal "17", budget["RateLimit-Remaining"] + + 20.times { ask } + assert_equal "0", budget["RateLimit-Remaining"], "it never goes negative" + end + end + + test "past the budget it refuses, and says when to come back" do + with_a_real_cache do + 20.times { ask } + assert_response :success + + ask + + assert_response :too_many_requests + assert_equal "rate_limited", body["error"] + assert body["retry_after"].to_i.between?(1, 60) + assert_equal response.headers["Retry-After"], body["retry_after"].to_s + end + end + + test "one token running hot does not spend another token's budget" do + with_a_real_cache do + other, spare = Api::Token.mint!("UOWNER2", "Arcade") + 21.times { ask } + assert_response :too_many_requests + + ask(spare) + + assert_response :success + assert_equal "19", budget["RateLimit-Remaining"] + assert_equal other.rate, budget["RateLimit-Limit"].to_i + end + end + + test "a token with its own limit is held to that, not the shared one" do + with_a_real_cache do + @token.update!(rate_limit: 2) + + 2.times { ask } + assert_response :success + assert_equal "2", budget["RateLimit-Limit"] + + ask + assert_response :too_many_requests + end + end + + test "a refusal before the token is known carries no budget at all" do + with_a_real_cache do + ask("nemo_live_neverissuedatall12") + + assert_response :unauthorized + assert_empty budget, "there is no budget to report without a token" + end + end + + test "the budget is spent on checks too, not only on the token route" do + with_a_real_cache do + channel = Analytics::DimChannel.first.channel_id + get api_v1_channel_manager_path(channel_id: channel, user_id: "U0BGRUHPTTR"), + headers: { "Authorization" => "Bearer #{@secret}" } + + assert_response :success + assert_equal "19", budget["RateLimit-Remaining"] + end + end +end diff --git a/web/test/integration/fd_access_test.rb b/web/test/integration/fd_access_test.rb index 8f33695a..3457dde0 100644 --- a/web/test/integration/fd_access_test.rb +++ b/web/test/integration/fd_access_test.rb @@ -66,7 +66,7 @@ def self.enforced end.map(&:first).uniq assert_equal %w[fd/settlements fd/supersessions fd/retirements fd/grants - fd/role_permissions fd/flags].sort, + fd/role_permissions fd/flags fd/api_settings].sort, lead_only.sort, "a lead-only route appeared or vanished, so this test needs updating" end diff --git a/web/test/integration/fd_api_settings_test.rb b/web/test/integration/fd_api_settings_test.rb new file mode 100644 index 00000000..c695f282 --- /dev/null +++ b/web/test/integration/fd_api_settings_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class FdApiSettingsTest < ActionDispatch::IntegrationTest + setup do + @boss = Staff.create!(user_id: "UBOSS9", community_manager: true) + Fd::AccessGrant.give!("UFIRE9", role: "firefighter", by: @boss.user_id) + @token, = Api::Token.mint!("UOWNER9", "Toolbox") + sign_in_as(@boss) + end + + def dials + Api::Setting::DEFAULTS.keys.index_with { |key| Api::Setting.value(key) } + end + + test "the tab lists every token with its owner and its rate" do + get fd_settings_path(tab: "api") + + assert_response :success + assert_select ".data-table td", text: "Toolbox" + assert_select ".data-table td.mono", text: @token.shown + assert_select ".fbox .row-k", text: "Requests a minute, per token" + end + + test "a manager moves a dial, and it is written down" do + assert_difference -> { Api::Event.count }, 1 do + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 250 } + end + + assert_equal 250, Api::Setting.value("rate_per_minute") + said = Api::Event.last + assert_equal ["setting_changed", @boss.user_id, "20 to 250"], + [said.verb, said.actor_user_id, said.detail] + end + + test "a dial that does not exist, or a silly number, changes nothing" do + was = dials + + patch fd_api_setting_path, params: { key: "wingspan", value: 5 } + assert_match(/not a setting/, flash[:alert]) + + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 0 } + assert_match(/above nought/, flash[:alert]) + + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 999_999_999 } + assert_match(/more than anybody needs/, flash[:alert]) + + assert_equal was, dials + assert_equal 0, Api::Event.count + end + + test "a manager gives one token its own rate, and takes it away again" do + patch fd_api_token_rate_path(@token), params: { value: 600 } + assert_equal 600, @token.reload.rate + + patch fd_api_token_rate_path(@token), params: { value: "" } + assert_nil @token.reload.rate_limit + assert_equal Api::Setting.value("rate_per_minute"), @token.rate + assert_equal 2, Api::Event.where(verb: "token_rate_set").count + end + + test "a manager revokes somebody else's token, and it is named to them" do + delete fd_api_token_path(@token) + + assert_predicate @token.reload, :revoked? + assert_equal @boss.user_id, @token.revoked_by + said = Api::Event.where(verb: "token_revoked").sole + assert_match(/owned by UOWNER9/, said.detail) + end + + test "revoking the same token twice is refused rather than logged twice" do + delete fd_api_token_path(@token) + + assert_no_difference -> { Api::Event.count } do + delete fd_api_token_path(@token) + end + + assert_match(/no live token/, flash[:alert]) + end + + test "a firefighter cannot move a dial or touch a token" do + sign_in_as(Staff.find("UFIRE9")) + + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 999 } + delete fd_api_token_path(@token) + + assert_equal 20, Api::Setting.value("rate_per_minute") + assert_not_predicate @token.reload, :revoked? + assert_equal 0, Api::Event.count + end + + test "a firefighter does not even see the tab" do + sign_in_as(Staff.find("UFIRE9")) + get fd_settings_path(tab: "api") + + assert_select ".views .view", text: /API/, count: 0 + end +end diff --git a/web/test/integration/member_area_test.rb b/web/test/integration/member_area_test.rb new file mode 100644 index 00000000..932da661 --- /dev/null +++ b/web/test/integration/member_area_test.rb @@ -0,0 +1,120 @@ +require "test_helper" + +class MemberAreaTest < ActionDispatch::IntegrationTest + setup do + @member = Staff.create!(user_id: "UMEMBER1") + @staff = Staff.create!(user_id: "UBOSS1", community_manager: true) + end + + def gated_paths + [root_path, fd_root_path, fd_cases_path, fd_members_path, fd_settings_path, + fd_audit_path, fd_decisions_path, fd_search_path, channels_path, engine_path, + acquisition_journey_path] + end + + test "a member with no role signs in and lands on their own page" do + sign_in_as(@member) + + assert_redirected_to you_api_path + follow_redirect! + assert_response :success + end + + test "a member with no role holds no permission at all" do + assert_nil @member.role + Fd::Permission.keys.each do |key| + assert_not @member.may?(key), "a member with no role must not hold #{key}" + end + end + + test "every other page is still shut to a member with no role" do + sign_in_as(@member) + + gated_paths.each do |path| + get path + assert_redirected_to auth_failure_path(message: "not_allowlisted"), + "#{path} let a member with no role through" + end + end + + test "a member with no role cannot write to fire engine either" do + kase = make_case + + sign_in_as(@member) + post fd_case_claim_path(kase) + + assert_redirected_to auth_failure_path(message: "not_allowlisted") + assert_empty kase.reload.assignees + end + + test "a json request from a member with no role is refused, not redirected" do + sign_in_as(@member) + get fd_search_path(format: :json) + + assert_response :unauthorized + end + + test "signed out, the member page sends you to sign in" do + get you_api_path + + assert_redirected_to login_path + end + + test "signing out shuts the member page again" do + sign_in_as(@member) + get you_api_path + assert_response :success + + delete logout_path + get you_api_path + + assert_redirected_to login_path + end + + test "an identity that is not a slack id is given no session at all" do + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:hackclub] = OmniAuth::AuthHash.new( + provider: "hackclub", uid: "ident!nonsense", info: {}, + extra: { raw_info: { "slack_id" => "../../etc/passwd" } } + ) + get "/auth/hackclub/callback" + + assert_redirected_to auth_failure_path(message: "no_slack_id") + + get you_api_path + assert_redirected_to login_path + end + + test "the member page shows no fire engine and no analytics in the rail" do + sign_in_as(@member) + get you_api_path + + assert_select ".rail-item[href=?]", you_api_path + assert_select ".rail-item[href=?]", fd_cases_path, count: 0 + assert_select ".rail-item[href=?]", channels_path, count: 0 + assert_select ".rail-item[href=?]", engine_path, count: 0 + assert_select ".rail-find", { count: 0 }, "the search palette is firefighters only" + end + + test "a firefighter keeps their whole rail and gains the account section" do + sign_in_as(@staff) + get you_api_path + + assert_response :success + assert_select ".rail-item[href=?]", you_api_path + assert_select ".rail-item[href=?]", fd_cases_path + end + + test "signing in as a firefighter still lands on the dashboard, not the member page" do + sign_in_as(@staff) + + assert_redirected_to root_path + end + + test "the sign in page sends a signed in member on rather than looping" do + sign_in_as(@member) + get login_path + + assert_redirected_to you_api_path + end +end diff --git a/web/test/integration/member_consent_test.rb b/web/test/integration/member_consent_test.rb new file mode 100644 index 00000000..ab88f11e --- /dev/null +++ b/web/test/integration/member_consent_test.rb @@ -0,0 +1,113 @@ +require "test_helper" + +class MemberConsentTest < ActionDispatch::IntegrationTest + CAP = "channel_manager".freeze + + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @member = Staff.create!(user_id: "UMEMBER2") + sign_in_as(@member) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def flip(on, **extra) + patch you_consent_path(capability: CAP, on: on, **extra) + end + + def state + Api::Consent.find_by(user_id: @member.user_id, capability: CAP)&.state + end + + test "opting in writes the consent and exactly one log line" do + assert_difference -> { Api::ConsentLog.count }, 1 do + flip("1") + end + + assert_equal "granted", state + assert_equal "dashboard", Api::ConsentLog.last.via + assert_equal @member.user_id, Api::ConsentLog.last.user_id + end + + test "opting out again withholds it and logs the second move" do + flip("1") + assert_difference -> { Api::ConsentLog.count }, 1 do + flip("0") + end + + assert_equal "withheld", state + assert_equal %w[granted withheld], Api::ConsentLog.order(:at, :id).pluck(:state) + end + + test "the first grant is remembered even after opting out" do + flip("1") + first = Api::Consent.find_by(user_id: @member.user_id, capability: CAP).first_granted_at + flip("0") + + assert_equal first, Api::Consent.find_by(user_id: @member.user_id, capability: CAP) + .first_granted_at + end + + test "a member can only ever move their own row" do + flip("1", user_id: "UVICTIM", member_id: "UVICTIM") + + assert_equal "granted", state + assert_nil Api::Consent.find_by(user_id: "UVICTIM") + assert_empty Api::ConsentLog.where(user_id: "UVICTIM") + end + + test "a capability nobody declared is refused and writes nothing" do + assert_no_difference -> { Api::ConsentLog.count } do + patch you_consent_path(capability: "read_my_email", on: "1") + end + + assert_redirected_to you_api_path + assert_match(/not a capability/, flash[:alert]) + assert_empty Api::Consent.all + end + + test "nothing moves while the public api is turned off" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + + assert_no_difference -> { Api::ConsentLog.count } do + flip("1") + end + + assert_nil state + assert_match(/turned off/, flash[:alert]) + end + + test "the page offers opting in, then opting out, and counts what is on" do + get you_api_path + + assert_select ".cap-row .btn", text: "Opt in" + assert_select ".facts .frow b", text: /nothing/ + + flip("1") + get you_api_path + + assert_select ".cap-row .btn", text: "Opt out" + assert_select ".facts .frow b", text: /1 of 1/ + end + + test "the switch is dead on the page while the public api is off" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + get you_api_path + + assert_select ".cap-row .btn.is-off" + assert_select ".cap-row .btn[disabled]" + end + + test "signed out, nobody can move consent at all" do + delete logout_path + + assert_no_difference -> { Api::ConsentLog.count } do + flip("1") + end + + assert_redirected_to login_path + end +end diff --git a/web/test/integration/member_tokens_test.rb b/web/test/integration/member_tokens_test.rb new file mode 100644 index 00000000..fa97482b --- /dev/null +++ b/web/test/integration/member_tokens_test.rb @@ -0,0 +1,121 @@ +require "test_helper" + +class MemberTokensTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @member = Staff.create!(user_id: "UMEMBER4") + sign_in_as(@member) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def mint(name: "Toolbox", lasting: "90") + post you_tokens_path, params: { name: name, lasting: lasting } + end + + test "the form does not go through turbo, so the answer is not thrown away" do + get you_api_path(tab: "tokens") + + assert_select "form[action=?][data-turbo=false]", you_tokens_path, 1, + "turbo drive discards a 200 from a form post, and the key would never be seen" + end + + test "creating a key renders the one and only time it is shown" do + assert_difference -> { Api::Token.count }, 1 do + mint + end + + assert_response :success + assert_select ".modal-title", text: "Copy your key" + assert_select ".secret code", 1 + assert_select ".warn-line" + end + + test "the key on screen is the real one, and is not what we stored" do + mint + shown = css_select(".secret code").sole.text + token = Api::Token.sole + + assert_equal Api::Token.digest_of(shown), token.digest + assert_not_equal shown, token.digest + assert shown.start_with?(Api::Token::LEAD) + end + + test "the chosen expiry is the one written down" do + mint(lasting: "30") + + assert_in_delta 30.days.from_now, Api::Token.sole.expires_at, 1.minute + end + + test "no expiry is a real choice, not a default" do + mint(lasting: "never") + + assert_nil Api::Token.sole.expires_at + end + + test "a made up expiry falls back rather than storing nothing" do + mint(lasting: "forever and ever") + + assert_in_delta 90.days.from_now, Api::Token.sole.expires_at, 1.minute + end + + test "an unnamed key is refused" do + assert_no_difference -> { Api::Token.count } do + mint(name: " ") + end + + assert_redirected_to you_api_path(tab: "tokens") + end + + test "minting is written to the audit log with its life" do + mint(lasting: "30") + + said = Api::Event.where(verb: "token_minted").sole + assert_equal @member.user_id, said.actor_user_id + assert_equal "Toolbox, 30 days", said.detail + end + + test "past the cap it is refused, and a revoked key frees a slot" do + 3.times { |i| mint(name: "key #{i}") } + assert_equal 3, Api::Token.count + + mint(name: "one too many") + assert_equal 3, Api::Token.count + assert_match(/already hold 3/, flash[:alert]) + + Api::Token.first.revoke!(by: @member.user_id) + mint(name: "room now") + + assert_equal 4, Api::Token.count + end + + test "an expired key does not hold a slot" do + 3.times { |i| mint(name: "key #{i}") } + Api::Token.first.update!(expires_at: 1.day.ago) + + mint(name: "room now") + + assert_equal 4, Api::Token.count + end + + test "nobody can mint while the public api is off" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + + assert_no_difference -> { Api::Token.count } do + mint + end + end + + test "signed out, nobody can mint at all" do + delete logout_path + + assert_no_difference -> { Api::Token.count } do + mint + end + + assert_redirected_to login_path + end +end diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb index 5a38f5fe..11750434 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -8,6 +8,10 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest INSIDE = %i[root_path fd_root_path fd_members_path fd_decisions_path fd_settings_path].freeze + MEMBER = %w[you/api you/consents you/tokens docs].freeze + + BEARER = %w[api/v1/tokens api/v1/channel_managers].freeze + setup do Rails.application.eager_load! @me = Staff.create!(user_id: "UNOROLE", community_manager: false) @@ -17,13 +21,26 @@ def self.controllers Rails.application.routes.routes.filter_map { |route| route.defaults[:controller] }.uniq end + def filters_of(name) + "#{name}_controller".camelize.constantize._process_action_callbacks.map(&:filter) + end + def guarded?(name) - "#{name}_controller".camelize.constantize._process_action_callbacks - .any? { |callback| callback.filter == :require_staff } + filters_of(name).include?(:require_staff) + end + + def member_guarded?(name) + filters_of(name).include?(:require_a_member) + end + + def token_guarded?(name) + filters_of(name).include?(:require_a_token) end def guarded_controllers - (self.class.controllers - OPEN).select { |name| guarded?(name) } + (self.class.controllers - OPEN).select do |name| + guarded?(name) || member_guarded?(name) || token_guarded?(name) + end end test "the only routes open to the world are signing in and the health check" do @@ -31,10 +48,40 @@ def guarded_controllers "a route opened up or closed, so this test needs updating" end - test "every controller behind the login demands a role, not merely a session" do - (self.class.controllers - OPEN).each do |name| + test "every controller behind the login demands a role, bar the member area and the api" do + (self.class.controllers - OPEN - MEMBER - BEARER).each do |name| assert guarded?(name), "#{name} lets anybody through" + assert_not member_guarded?(name), "#{name} settles for a session where a role is needed" + end + end + + test "the member area demands a session and never a role, and is only what is listed" do + MEMBER.each do |name| + assert member_guarded?(name), "#{name} lets anybody through" + assert_not guarded?(name), "#{name} still demands a role, so it is not a member page" + end + + assert_equal MEMBER.sort, + (self.class.controllers - OPEN - BEARER).reject { |name| guarded?(name) }.sort, + "a controller dropped its role check, so this test needs updating" + end + + test "the api demands a token, never a session or a role, and is only what is listed" do + BEARER.each do |name| + assert token_guarded?(name), "#{name} lets anybody through" + assert_not guarded?(name), "#{name} demands a role, so it is not reachable by a token" + assert_not member_guarded?(name), "#{name} demands a session, which an api caller has not got" end + + assert_equal BEARER.sort, + (self.class.controllers - OPEN).select { |name| token_guarded?(name) }.sort, + "a controller started taking bearer tokens, so this test needs updating" + end + + test "the api carries no session at all, so a browser cannot ride in on cookies" do + assert_not Api::V1::BaseController.ancestors.include?(ActionController::Cookies), + "an api controller that reads cookies can be driven by a logged in browser" + assert_not Api::V1::BaseController.ancestors.include?(ApplicationController) end test "the turbo routes are open because they carry nothing but a go back" do @@ -45,11 +92,17 @@ def guarded_controllers end end - test "holding no grant means no session, whatever the staff table says" do + test "holding no grant opens the member area and nothing behind it" do sign_in_as(@me) - assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] + assert_redirected_to you_api_path + assert_equal "UNOROLE", session[:user_id] + + INSIDE.each do |path| + get send(path) + assert_redirected_to auth_failure_path(message: "not_allowlisted"), + "#{path} took a session for a role" + end end test "a live grant is enough on its own, with no staff row behind it" do @@ -64,21 +117,24 @@ def guarded_controllers assert_response :success end - test "somebody unknown to the staff table is refused the same way" do + test "somebody unknown to the staff table gets the same member area and no more" do sign_in_as(Staff.new(user_id: "USTRANGER")) + assert_redirected_to you_api_path + get fd_cases_path assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] end test "the refusal says one thing, and offers the way back" do sign_in_as(@me) + get root_path follow_redirect! assert_response :success assert_select "h1", text: "Access denied" assert_select "p", text: /You are not allowlisted/ - assert_select "a[href=?]", login_path + assert_select "a[href=?]", you_api_path, 1, "the way out is their own page" + assert_select "form[action=?]", logout_path assert_select ".auth-alt", count: 0 end @@ -111,15 +167,16 @@ def guarded_controllers assert_redirected_to auth_failure_path(message: "not_allowlisted") end - test "a stale session lands on the sign in page rather than bouncing forever" do + test "a stale session lands on the member area rather than bouncing forever" do grant = Fd::AccessGrant.give!("UNOROLE", role: "firefighter", by: "UBOSS") sign_in_as(@me) grant.take_back!(by: "UBOSS") get login_path + assert_redirected_to you_api_path + follow_redirect! assert_response :success - assert_select "h1", text: /sign in/i end test "the sign in switch for development is not routed anywhere else" do diff --git a/web/test/models/channel_managers_test.rb b/web/test/models/channel_managers_test.rb new file mode 100644 index 00000000..8ca18ab9 --- /dev/null +++ b/web/test/models/channel_managers_test.rb @@ -0,0 +1,202 @@ +require "test_helper" + +class ChannelManagersTest < ActiveSupport::TestCase + CHANNEL = "C0BGRUMA85D".freeze + + setup do + @asked = [] + @was_role = ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] + ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = "Rl0A" + Api::ChannelManager.delete_all + Api::ChannelSweep.delete_all + Rails.cache.clear + end + + teardown do + ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = @was_role + end + + def swapping(replier) + was = Slack::ProxyClient.method(:call) + Slack::ProxyClient.define_singleton_method(:call, &replier) + yield + ensure + Slack::ProxyClient.define_singleton_method(:call, was) + end + + def page(*user_ids, on: CHANNEL, cursor: nil) + { + "ok" => true, + "role_assignments" => user_ids.map do |user_id| + { "role_id" => "Rl0A", "entity_id" => on, "user_id" => user_id, + "date_create" => 1_700_000_000 } + end, + "response_metadata" => { "next_cursor" => cursor.to_s } + } + end + + def answering(*pages, &block) + replies = pages.dup + asked = @asked + fallback = page + swapping(lambda { |method, params, **options| + asked << [method, params, options] + replies.shift || fallback + }, &block) + end + + def raising(error, &block) + asked = @asked + swapping(lambda { |*_args, **_options| + asked << :tried + raise error + }, &block) + end + + test "a channel nobody has asked about is fetched once and remembered" do + found = answering(page("U1", "U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1 U2], found + assert_equal 1, @asked.size + assert_equal %w[U1 U2], Api::ChannelManager.user_ids_in(CHANNEL) + assert_equal 2, Api::ChannelSweep.find(CHANNEL).managers + end + + test "it asks slack for one channel and one role, never the whole workspace" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + + method, params, options = @asked.sole + assert_equal "admin.roles.listAssignments", method + assert_equal "Rl0A", params[:role_ids] + assert_equal CHANNEL, params[:entity_ids] + assert_equal "admin", options[:credential] + end + + test "a fresh channel is answered without troubling slack at all" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + @asked.clear + + found = answering { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], found + assert_empty @asked, "a warm channel must cost nothing" + end + + test "a channel nobody manages is remembered as empty, not refetched forever" do + answering(page) { ChannelManagers.for(CHANNEL) } + @asked.clear + + assert_empty answering { ChannelManagers.for(CHANNEL) } + assert_empty @asked, "no rows is an answer, not a cold cache" + assert_equal 0, Api::ChannelSweep.find(CHANNEL).managers + end + + test "once the stamp goes stale it is fetched again" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + @asked.clear + + found = answering(page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U2], found + assert_equal 1, @asked.size + end + + test "a refresh replaces the channel, so somebody who stepped down stops managing it" do + answering(page("U1", "U2")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + + answering(page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U2], Api::ChannelManager.user_ids_in(CHANNEL) + assert_not ChannelManagers.manages?(CHANNEL, "U1") + end + + test "a stale channel already being refreshed is not fetched a second time" do + with_a_real_cache do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + @asked.clear + ChannelManagers.claim(CHANNEL) + + found = answering(page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_empty @asked, "somebody else holds the lock, so this caller serves what we have" + assert_equal %w[U1], found + end + end + + test "a cold channel is always fetched, lock or no lock" do + with_a_real_cache do + ChannelManagers.claim(CHANNEL) + + found = answering(page("U1")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], found, + "answering false about a channel we never read is worse than waiting" + end + end + + test "a refreshed channel is fresh again, so the next caller asks nothing" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + @asked.clear + + answering(page("U2")) { ChannelManagers.for(CHANNEL) } + answering(page("U3")) { ChannelManagers.for(CHANNEL) } + + assert_equal 1, @asked.size + assert_equal %w[U2], Api::ChannelManager.user_ids_in(CHANNEL) + end + + test "slack falling over serves what we already hold and leaves the stamp alone" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + stamped = Api::ChannelSweep.find(CHANNEL).synced_at + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + + found = raising(Slack::ProxyClient::Unavailable) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], found + assert_not_equal stamped, Api::ChannelSweep.find(CHANNEL).synced_at + assert_equal %w[U1], Api::ChannelManager.user_ids_in(CHANNEL) + end + + test "a refusal from slack does not wipe the channel" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + + answering({ "ok" => false, "error" => "missing_scope" }) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], Api::ChannelManager.user_ids_in(CHANNEL) + end + + test "an assignment on some other channel is dropped, whatever slack sends" do + answering(page("U1", on: "COTHER")) { ChannelManagers.for(CHANNEL) } + + assert_empty Api::ChannelManager.user_ids_in(CHANNEL) + assert_equal 0, Api::ChannelSweep.find(CHANNEL).managers + end + + test "it follows the cursor to the end" do + found = answering(page("U1", cursor: "more"), page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1 U2], found + assert_equal 2, @asked.size + assert_nil @asked.first[1][:cursor], "the first call sends no cursor at all" + assert_equal "more", @asked.last[1][:cursor] + end + + test "with no role id pinned it asks nothing and claims nothing" do + ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = nil + found = answering(page("U1")) { ChannelManagers.for(CHANNEL) } + + assert_empty found + assert_empty @asked + assert_nil Api::ChannelSweep.find_by(channel_id: CHANNEL) + end + + test "a blank channel id is answered without a query" do + assert_empty answering { ChannelManagers.for("") } + assert_empty @asked + end +end diff --git a/web/test/models/fd/deeds_test.rb b/web/test/models/fd/deeds_test.rb index aef76bbf..35c3d7e7 100644 --- a/web/test/models/fd/deeds_test.rb +++ b/web/test/models/fd/deeds_test.rb @@ -114,6 +114,140 @@ def deeds(only: nil, **opts) assert_equal %w[case/resolved case/claimed], deeds(limit: 2).map(&:event) end + def both_kinds + audit("case", make_case.id, "opened") + AccessLog.create!(actor_id: WHO, subject_user_id: "USUB", field_class: "identity", + looked_at: 2.days.ago) + end + + def all_three + both_kinds + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + end + + test "a view keeps only its own source, and no view keeps nothing" do + all_three + + assert_equal %w[case/opened consent/granted identity/read], deeds.map(&:event).sort + assert_equal ["case/opened"], deeds(view: "audit").map(&:event) + assert_equal ["identity/read"], deeds(view: "read").map(&:event) + assert_equal ["consent/granted"], deeds(view: "api").map(&:event) + end + + test "a consent change names the capability and how it was made, not a member link" do + Api::Consent.set!(WHO, "channel_manager", true, via: "command") + + row = deeds(view: "api").sole + assert_equal ["capability", "channel manager lookups"], [row.kind, row.about] + assert_equal ["from Slack", WHO], [row.said, row.actor] + assert_nil row.id, "a consent row links nothing, it is about the person who made it" + end + + test "opting back out reads as its own line, and both survive" do + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + Api::Consent.set!(WHO, "channel_manager", false, via: "dashboard") + + assert_equal ["consent/granted", "consent/withheld"], deeds(view: "api").map(&:event).sort, + "one line each way, whatever order a shared timestamp puts them in" + end + + def checked(token, on: "C0DESIGN99", subjects: ["USUB"], outcome: "manager", at: 1.hour.ago) + Api::RequestLog.insert_all(subjects.map do |subject| + { token_id: token.id, channel_id: on, subject_user_id: subject, + outcome: outcome, at: at } + end) + end + + test "a day of api traffic reads as one line, not one line a call" do + token, = Api::Token.mint!(WHO, "Toolbox") + checked(token, subjects: %w[U1 U2 U3]) + + row = deeds(view: "api").sole + assert_equal ["api/checked", "3 members", WHO], [row.event, row.about, row.actor] + assert_equal "Toolbox · 1 channel", row.said + end + + test "the roll up counts channels and what was withheld" do + token, = Api::Token.mint!(WHO, "Toolbox") + checked(token, on: "C0ONE", subjects: %w[U1 U2]) + checked(token, on: "C0TWO", subjects: %w[U3], outcome: "withheld") + + assert_equal "Toolbox · 2 channels · 1 withheld", deeds(view: "api").sole.said + end + + test "two days of traffic are two lines, and two tokens are two more" do + mine, = Api::Token.mint!(WHO, "Toolbox") + theirs, = Api::Token.mint!("UOTHER", "Arcade") + checked(mine, at: 1.hour.ago) + checked(mine, at: 2.days.ago) + checked(theirs, at: 1.hour.ago) + + assert_equal 3, Fd::Deeds.new(nil, since: 30.days.ago, view: "api").rows.size + end + + test "a token owner sees their own traffic and not somebody else's" do + mine, = Api::Token.mint!(WHO, "Toolbox") + theirs, = Api::Token.mint!("UOTHER", "Arcade") + checked(mine) + checked(theirs) + + assert_equal ["Toolbox · 1 channel"], deeds(view: "api").map(&:said) + end + + test "the api count covers consent, events and traffic alike" do + token, = Api::Token.mint!(WHO, "Toolbox") + checked(token, subjects: %w[U1 U2]) + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + Api::Event.record!("token_minted", actor: WHO, subject: token.shown, detail: "Toolbox") + + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + assert_equal 3, counted["api"], "one roll up, one consent line, one event" + assert_equal deeds(view: "api").size, counted["api"] + end + + test "consent changes are asked for by nobody looking at one permission" do + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + audit("case", make_case.id, "opened") + + assert_equal ["case/opened"], deeds(only: "case.open").map(&:event) + assert_empty deeds(only: "identity.read") + end + + test "an unknown view falls back to everything rather than to nothing" do + both_kinds + + assert_equal deeds.size, deeds(view: "nonsense").size + assert_equal "all", Fd::Deeds.view_for("nonsense") + assert_equal "all", Fd::Deeds.view_for(nil) + end + + test "the per-view counts add up to the unfiltered total" do + all_three + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + + assert_equal 1, counted["audit"] + assert_equal 1, counted["read"] + assert_equal 1, counted["api"] + assert_equal counted["all"], counted["audit"] + counted["read"] + counted["api"] + assert_equal Fd::Deeds.new(WHO, since: 30.days.ago).total, counted["all"] + end + + test "counting a view agrees with paging it" do + all_three + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + + Fd::Deeds::VIEWS.each_key do |key| + assert_equal deeds(view: key).size, counted.fetch(key), + "#{key} counts one way and pages another" + end + end + + test "a view still counts every source when there is nothing to count" do + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + + assert_equal({ "audit" => 0, "read" => 0, "api" => 0, "all" => 0 }, counted) + end + test "the members it mentions are the ones a name is needed for" do kase = make_case action = Fd::Action.create!(case_id: kase.id, type_key: "warning", target_user_id: "USUB", diff --git a/web/test/test_helper.rb b/web/test/test_helper.rb index 8ce9ff09..568892ad 100644 --- a/web/test/test_helper.rb +++ b/web/test/test_helper.rb @@ -26,6 +26,14 @@ def make_case(subject: "USUB", assign: nil, **attrs) kase end + def with_a_real_cache + was = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + yield + ensure + Rails.cache = was + end + def sign_in_as(staff) OmniAuth.config.test_mode = true OmniAuth.config.mock_auth[:hackclub] = OmniAuth::AuthHash.new(