diff --git a/db/api_scopes.yml b/db/api_scopes.yml new file mode 100644 index 00000000..2012b0b9 --- /dev/null +++ b/db/api_scopes.yml @@ -0,0 +1,4 @@ +scopes: + channel_manager: + label: Channel manager lookups + covers: Apps can ask whether you manage a given channel. diff --git a/db/capabilities.yml b/db/capabilities.yml index 1c93389a..b70171df 100644 --- a/db/capabilities.yml +++ b/db/capabilities.yml @@ -57,6 +57,7 @@ areas: slack: Your own account access: Access app: The dashboard + api: Public API record_scopes: [author, channel] @@ -253,3 +254,8 @@ capabilities: area: app locked: true events: [automod_word/added, automod_word/removed, app_setting/tuned] + + api.review: + label: Review requests for public API access + area: api + events: [access/requested, access/approved, access/declined] diff --git a/db/flags.yml b/db/flags.yml index 07c2714d..c5d43069 100644 --- a/db/flags.yml +++ b/db/flags.yml @@ -7,3 +7,9 @@ flags: label: Fire Engine covers: Cases, Members and the search palette default: true + public_api: + label: Public API + covers: Channel manager lookups, tokens and opt-in + audience: Everyone in Slack + default: false + listed: false diff --git a/db/init.sql b/db/init.sql index c9c16809..c92130c4 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; CREATE SCHEMA IF NOT EXISTS ingest; CREATE SCHEMA IF NOT EXISTS archive; @@ -104,6 +105,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/0153_api_and_channel_managers.sql b/db/migrations/0153_api_and_channel_managers.sql new file mode 100644 index 00000000..ee7f2c10 --- /dev/null +++ b/db/migrations/0153_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/0154_api_event_log.sql b/db/migrations/0154_api_event_log.sql new file mode 100644 index 00000000..a6c2df72 --- /dev/null +++ b/db/migrations/0154_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/0155_api_rate_default.sql b/db/migrations/0155_api_rate_default.sql new file mode 100644 index 00000000..8a1679af --- /dev/null +++ b/db/migrations/0155_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/0156_api_drop_batch_max.sql b/db/migrations/0156_api_drop_batch_max.sql new file mode 100644 index 00000000..ef51d329 --- /dev/null +++ b/db/migrations/0156_api_drop_batch_max.sql @@ -0,0 +1 @@ +DELETE FROM api.setting WHERE key = 'batch_max'; diff --git a/db/migrations/0157_api_token_expiry.sql b/db/migrations/0157_api_token_expiry.sql new file mode 100644 index 00000000..c36629a5 --- /dev/null +++ b/db/migrations/0157_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/db/migrations/0158_api_access_requests.sql b/db/migrations/0158_api_access_requests.sql new file mode 100644 index 00000000..9cd640c1 --- /dev/null +++ b/db/migrations/0158_api_access_requests.sql @@ -0,0 +1,37 @@ +CREATE TABLE api.access_request ( + id bigserial PRIMARY KEY, + user_id text NOT NULL, + scope text NOT NULL, + reason text NOT NULL, + state text NOT NULL DEFAULT 'pending', + created_at timestamptz NOT NULL DEFAULT now(), + decided_by text, + decided_at timestamptz, + note text, + CONSTRAINT access_request_state_known + CHECK (state IN ('pending', 'approved', 'declined', 'withdrawn')), + CONSTRAINT access_request_decided_together + CHECK ((state = 'pending') = (decided_at IS NULL)) +); + +CREATE UNIQUE INDEX access_request_one_open_idx + ON api.access_request (user_id, scope) WHERE state = 'pending'; + +CREATE INDEX access_request_queue_idx + ON api.access_request (created_at DESC) WHERE state = 'pending'; + +CREATE INDEX access_request_owner_idx ON api.access_request (user_id, created_at DESC); + +CREATE TABLE api.approval ( + user_id text NOT NULL, + scope text NOT NULL, + granted_by text NOT NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + revoked_by text, + revoked_at timestamptz, + PRIMARY KEY (user_id, scope) +); + +CREATE INDEX approval_live_idx ON api.approval (scope) WHERE revoked_at IS NULL; + +ALTER TABLE api.token ADD COLUMN rotated_at timestamptz; diff --git a/db/migrations/0159_api_apps.sql b/db/migrations/0159_api_apps.sql new file mode 100644 index 00000000..aac46a7b --- /dev/null +++ b/db/migrations/0159_api_apps.sql @@ -0,0 +1,76 @@ +CREATE TABLE api.app ( + id bigserial PRIMARY KEY, + slug text NOT NULL UNIQUE, + name text NOT NULL, + blurb text NOT NULL, + owner_user_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + retired_at timestamptz +); + +CREATE INDEX app_owner_idx ON api.app (owner_user_id); +CREATE INDEX app_live_idx ON api.app (name) WHERE retired_at IS NULL; + +DROP TABLE api.approval; + +CREATE TABLE api.approval ( + app_id bigint NOT NULL REFERENCES api.app (id), + scope text NOT NULL, + granted_by text NOT NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + revoked_by text, + revoked_at timestamptz, + PRIMARY KEY (app_id, scope) +); + +CREATE INDEX approval_live_idx ON api.approval (scope) WHERE revoked_at IS NULL; + +DROP TABLE api.consent; +DROP TABLE api.consent_log; + +CREATE TABLE api.consent ( + user_id text NOT NULL, + app_id bigint NOT NULL REFERENCES api.app (id), + 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, app_id, capability), + CONSTRAINT consent_state CHECK (state IN ('granted', 'withheld')) +); + +CREATE INDEX consent_granted_idx ON api.consent (app_id, capability) + WHERE state = 'granted'; + +CREATE TABLE api.consent_log ( + id bigserial PRIMARY KEY, + user_id text NOT NULL, + app_id bigint NOT NULL REFERENCES api.app (id), + 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); + +DELETE FROM api.request_log; +DELETE FROM api.token; + +ALTER TABLE api.token ADD COLUMN app_id bigint NOT NULL REFERENCES api.app (id); + +CREATE INDEX token_app_idx ON api.token (app_id); + +DELETE FROM api.access_request; + +ALTER TABLE api.access_request DROP COLUMN user_id; +ALTER TABLE api.access_request ADD COLUMN app_id bigint NOT NULL REFERENCES api.app (id); + +DROP INDEX IF EXISTS api.access_request_one_open_idx; +DROP INDEX IF EXISTS api.access_request_owner_idx; + +CREATE UNIQUE INDEX access_request_one_open_idx + ON api.access_request (app_id, scope) WHERE state = 'pending'; + +CREATE INDEX access_request_app_idx ON api.access_request (app_id, created_at DESC); diff --git a/db/migrations/0160_api_one_key_per_app.sql b/db/migrations/0160_api_one_key_per_app.sql new file mode 100644 index 00000000..23293f8d --- /dev/null +++ b/db/migrations/0160_api_one_key_per_app.sql @@ -0,0 +1,2 @@ +UPDATE api.setting SET value = 1, changed_at = now() +WHERE key = 'tokens_per_owner' AND value = 3; diff --git a/db/migrations/0161_api_approval_is_per_app.sql b/db/migrations/0161_api_approval_is_per_app.sql new file mode 100644 index 00000000..2aee0e1b --- /dev/null +++ b/db/migrations/0161_api_approval_is_per_app.sql @@ -0,0 +1,32 @@ +CREATE TABLE api.approval_per_app ( + app_id bigint PRIMARY KEY REFERENCES api.app (id), + granted_by text NOT NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + revoked_by text, + revoked_at timestamptz +); + +INSERT INTO api.approval_per_app (app_id, granted_by, granted_at) +SELECT DISTINCT ON (app_id) app_id, granted_by, granted_at +FROM api.approval +WHERE revoked_at IS NULL +ORDER BY app_id, granted_at; + +DROP TABLE api.approval; + +ALTER TABLE api.approval_per_app RENAME TO approval; + +CREATE INDEX approval_live_idx ON api.approval (app_id) WHERE revoked_at IS NULL; + +DROP INDEX IF EXISTS api.access_request_one_open_idx; + +DELETE FROM api.access_request a +WHERE EXISTS ( + SELECT 1 FROM api.access_request b + WHERE b.app_id = a.app_id AND b.state = 'pending' AND b.id < a.id AND a.state = 'pending' +); + +ALTER TABLE api.access_request DROP COLUMN scope; + +CREATE UNIQUE INDEX access_request_one_open_idx + ON api.access_request (app_id) WHERE state = 'pending'; diff --git a/db/migrations/0162_api_consent_can_be_for_every_app.sql b/db/migrations/0162_api_consent_can_be_for_every_app.sql new file mode 100644 index 00000000..2e4884a8 --- /dev/null +++ b/db/migrations/0162_api_consent_can_be_for_every_app.sql @@ -0,0 +1,13 @@ +ALTER TABLE api.consent DROP CONSTRAINT consent_pkey; + +ALTER TABLE api.consent ALTER COLUMN app_id DROP NOT NULL; + +CREATE UNIQUE INDEX consent_one_row_idx + ON api.consent (user_id, capability, coalesce(app_id, 0)); + +DROP INDEX IF EXISTS api.consent_granted_idx; + +CREATE INDEX consent_granted_idx ON api.consent (capability, app_id) + WHERE state = 'granted'; + +ALTER TABLE api.consent_log ALTER COLUMN app_id DROP NOT NULL; diff --git a/db/migrations/0163_api_consent_has_a_key.sql b/db/migrations/0163_api_consent_has_a_key.sql new file mode 100644 index 00000000..1bcb30df --- /dev/null +++ b/db/migrations/0163_api_consent_has_a_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE api.consent ADD COLUMN id bigserial; + +ALTER TABLE api.consent ADD CONSTRAINT consent_pkey PRIMARY KEY (id); diff --git a/deploy/env/serve.env.example b/deploy/env/serve.env.example index 6598df49..2e5e71c6 100644 --- a/deploy/env/serve.env.example +++ b/deploy/env/serve.env.example @@ -29,6 +29,7 @@ FD_ENCRYPTION_SALT= INTERNAL_PROXY_URL= PROMETHEUS_BASE_URL= SLACKSCAN_URL= +SLACK_CHANNEL_MANAGER_ROLE_ID= PROXY_TOKEN_WEB= PROXY_ALLOW_PLAINTEXT= RAILS_MAX_THREADS=5 diff --git a/pipeline/bot/manifests/nemo.yml b/pipeline/bot/manifests/nemo.yml index 874cde1f..7e8ecaf8 100644 --- a/pipeline/bot/manifests/nemo.yml +++ b/pipeline/bot/manifests/nemo.yml @@ -33,6 +33,7 @@ oauth_config: user: - admin - admin.analytics:read + - admin.roles:read - admin.conversations:read - admin.teams:read - admin.users:read diff --git a/pipeline/bot/nemo/cards/people.py b/pipeline/bot/nemo/cards/people.py index c1caeed0..e8187f12 100644 --- a/pipeline/bot/nemo/cards/people.py +++ b/pipeline/bot/nemo/cards/people.py @@ -68,7 +68,6 @@ def picked(state): return { "user_ids": values.get(WHO, {}).get(WHO, {}).get("selected_users") or [], "role": SUBJECT, - "detail": None, } diff --git a/pipeline/bot/nemo/casework.py b/pipeline/bot/nemo/casework.py index 8bd1886b..3e238ca4 100644 --- a/pipeline/bot/nemo/casework.py +++ b/pipeline/bot/nemo/casework.py @@ -58,7 +58,7 @@ def claimed(conn, case_id, user_id): PARTICIPANTS = """ -SELECT user_id, role, detail FROM fd.case_participants +SELECT user_id, role FROM fd.case_participants WHERE case_id = %s ORDER BY noted_at """ @@ -73,7 +73,7 @@ def claimed(conn, case_id, user_id): REMOVE_PARTICIPANT = """ DELETE FROM fd.case_participants WHERE case_id = %s AND user_id = %s AND role = %s -RETURNING detail +RETURNING user_id """ @@ -160,7 +160,7 @@ def claimed(conn, case_id, user_id): def participants(conn, case_id): return [ - {"user_id": row[0], "role": row[1], "detail": row[2]} + {"user_id": row[0], "role": row[1]} for row in conn.execute(PARTICIPANTS, (case_id,)).fetchall() ] @@ -186,7 +186,7 @@ def remove_participant(conn, case_id, user_id, role, by): audit.record( conn, "participant", case_id, "detached", by, - before={"user_id": user_id, "role": role, "detail": row[0]}, + before={"user_id": user_id, "role": role}, after=None, ) return True diff --git a/pipeline/bot/nemo/chat.py b/pipeline/bot/nemo/chat.py index c0e0653f..46169490 100644 --- a/pipeline/bot/nemo/chat.py +++ b/pipeline/bot/nemo/chat.py @@ -61,6 +61,18 @@ """ +REACHES_A_MEMBER = """ +SELECT 1 FROM fd.intake_messages m +JOIN fd.intake_conversations c ON c.id = m.conversation_id +WHERE m.mirrored_ts = %s +LIMIT 1 +""" + + +def reaches_a_member(conn, thread_ts): + return conn.execute(REACHES_A_MEMBER, (thread_ts,)).fetchone() is not None + + def case_of_thread(conn, thread_ts): row = conn.execute(CASE_OF_THREAD, {"ts": thread_ts}).fetchone() return row[0] if row else None diff --git a/pipeline/bot/nemo/handlers.py b/pipeline/bot/nemo/handlers.py index f2155885..938fbd11 100644 --- a/pipeline/bot/nemo/handlers.py +++ b/pipeline/bot/nemo/handlers.py @@ -513,14 +513,22 @@ def on_message(event, client): return aimed = answer.read(event.get("text")) - if aimed is None: - return ours(event, thread_ts, client) + if aimed and for_the_member(thread_ts): + return reply(event, thread_ts, aimed, client) + return ours(event, thread_ts, client) + + def for_the_member(thread_ts): + with session() as conn: + return chat.reaches_a_member(conn, thread_ts) + + def reply(event, thread_ts, aimed, client): if on_reply is None: - return + return None if not may_answer(event, thread_ts, client): - return - on_reply( + return None + + return on_reply( thread_ts, aimed["said"], event.get("user"), diff --git a/pipeline/bot/nemo/surface/reaction_watch.py b/pipeline/bot/nemo/surface/reaction_watch.py index 0b9fd8d4..effe0d45 100644 --- a/pipeline/bot/nemo/surface/reaction_watch.py +++ b/pipeline/bot/nemo/surface/reaction_watch.py @@ -1,7 +1,7 @@ import logging from bot.core import access, session, whoami -from bot.nemo import casework, channels, queued +from bot.nemo import casework, channels, chat, queued from bot.nemo.surface import on_event log = logging.getLogger("bot.nemo") @@ -54,12 +54,26 @@ def wanted(ctx, marks, needs): return channel_id, thread_ts, said, who +def worth_keeping(said): + return bool( + said.get("ts") and said.get("user") and (said.get("text") or said.get("blocks")) + ) + + +def keep_the_root(conn, case_id, channel_id, said): + if not worth_keeping(said): + return None + + chat_id, _ = chat.keep(conn, case_id, dict(said, channel=channel_id)) + return chat_id + + @on_event("reaction_added", open_to_all=True) def opened(ctx): asked = wanted(ctx, OPENS, "case.open") if asked is None: return None - channel_id, thread_ts, _, who = asked + channel_id, thread_ts, said, who = asked with session() as conn: standing = queued.case_on(conn, channel_id, thread_ts) @@ -68,6 +82,7 @@ def opened(ctx): return standing case_id = casework.open_case(conn, None, None, who) + keep_the_root(conn, case_id, channel_id, said) queued.post(ctx.client, conn, case_id, channel_id, thread_ts) log.info("nemo: case %s opened on %s in %s by %s", case_id, thread_ts, channel_id, who) diff --git a/pipeline/jobs/slack_roles.py b/pipeline/jobs/slack_roles.py new file mode 100644 index 00000000..d4b68c76 --- /dev/null +++ b/pipeline/jobs/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 3b2c3e8b..00a11def 100644 --- a/pipeline/lib/config.py +++ b/pipeline/lib/config.py @@ -56,6 +56,7 @@ "INTERNAL_PROXY_URL", "PROMETHEUS_BASE_URL", "SLACKSCAN_URL", + "SLACK_CHANNEL_MANAGER_ROLE_ID", "PROXY_TOKEN_WEB", "PROXY_ALLOW_PLAINTEXT", "RAILS_MAX_THREADS", diff --git a/pipeline/tests/test_case_chat_routing.py b/pipeline/tests/test_case_chat_routing.py new file mode 100644 index 00000000..41913231 --- /dev/null +++ b/pipeline/tests/test_case_chat_routing.py @@ -0,0 +1,119 @@ +import contextlib + +import pytest + +from bot.nemo import handlers +from bot.nemo.surface import ENTRIES, reaction_watch + + +class App: + def action(self, key): + return lambda fn: fn + + def view(self, key): + return lambda fn: fn + + +class Ctx: + def __init__(self, payload): + self.payload = payload + self.client = None + + +class Conn: + def __init__(self): + self.ran = [] + + def execute(self, sql, args=None): + self.ran.append((sql, args)) + return self + + def fetchone(self): + return None + + +def watcher(): + return next(one for one in ENTRIES if one.tag == "handlers.on_message") + + +SAID = {"channel": "C_FIRE", "ts": "2.0", "thread_ts": "1.0", "user": "UFD"} + + +@pytest.fixture +def wired(monkeypatch): + told = {"replied": [], "kept": []} + + handlers.register(App(), lambda *args, **kw: told["replied"].append((args, kw))) + + @contextlib.contextmanager + def one_session(): + yield Conn() + + monkeypatch.setattr(handlers, "session", one_session) + monkeypatch.setattr(handlers, "case_channels", lambda conn=None: {"C_FIRE"}) + monkeypatch.setattr(handlers.chat, "case_of_thread", lambda conn, ts: 6) + monkeypatch.setattr(handlers.access, "may", lambda conn, who, needs, case=None: (True, None)) + monkeypatch.setattr( + handlers.chat, "keep", + lambda conn, case_id, event: (told["kept"].append((case_id, event)), (1, True))[1], + ) + return told + + +def reaching(monkeypatch, answer): + monkeypatch.setattr(handlers.chat, "reaches_a_member", lambda conn, ts: answer) + + +def test_a_question_on_a_report_thread_goes_to_the_member(wired, monkeypatch): + reaching(monkeypatch, True) + watcher().fn(Ctx(dict(SAID, text="?are you ok"))) + + assert wired["replied"][0][0][1] == "are you ok" + assert wired["kept"] == [] + + +def test_a_question_with_no_member_to_reach_is_kept_as_chat(wired, monkeypatch): + reaching(monkeypatch, False) + watcher().fn(Ctx(dict(SAID, text="?are you ok"))) + + assert wired["replied"] == [] + assert wired["kept"][0][0] == 6 + + +def test_plain_chat_never_asks_whether_a_member_can_be_reached(wired, monkeypatch): + monkeypatch.setattr(handlers.chat, "reaches_a_member", lambda conn, ts: 1 / 0) + watcher().fn(Ctx(dict(SAID, text="who is handling this"))) + + assert wired["kept"][0][0] == 6 + + +ROOT = {"ts": "1.0", "user": "UMEM", "text": "that's my alt"} + + +def test_the_hourglassed_message_becomes_the_first_chat(monkeypatch): + kept = [] + monkeypatch.setattr( + reaction_watch.chat, "keep", + lambda conn, case_id, event: (kept.append((case_id, event)), (7, True))[1], + ) + assert reaction_watch.keep_the_root(None, 6, "C_FIRE", ROOT) == 7 + assert kept[0][1]["channel"] == "C_FIRE" + assert kept[0][1]["text"] == "that's my alt" + + +@pytest.mark.parametrize("said", [ + {}, + {"ts": "1.0", "user": "UMEM"}, + {"ts": "1.0", "text": "a bot said this"}, + {"user": "UMEM", "text": "no timestamp"}, +]) +def test_a_root_the_chat_table_would_refuse_is_left_alone(said, monkeypatch): + monkeypatch.setattr( + reaction_watch.chat, "keep", + lambda conn, case_id, event: 1 / 0, + ) + assert reaction_watch.keep_the_root(None, 6, "C_FIRE", said) is None + + +def test_a_root_with_only_blocks_is_still_worth_keeping(): + assert reaction_watch.worth_keeping({"ts": "1.0", "user": "U1", "blocks": [{}]}) diff --git a/pipeline/tests/test_case_people.py b/pipeline/tests/test_case_people.py new file mode 100644 index 00000000..fa08e484 --- /dev/null +++ b/pipeline/tests/test_case_people.py @@ -0,0 +1,58 @@ +from bot.nemo import casework +from bot.nemo.cards import people + +DROPPED = "detail" + + +class Conn: + def __init__(self, rows=(), one=None): + self.rows = rows + self.one = one + self.ran = [] + + def execute(self, sql, args=None): + self.ran.append((sql, args)) + return self + + def fetchall(self): + return self.rows + + def fetchone(self): + return self.one + + def did(self, mark): + return [args for sql, args in self.ran if mark in sql] + + +def test_the_people_on_a_case_are_a_user_and_a_role(): + conn = Conn(rows=[("U1", "subject"), ("U2", "reporter")]) + + assert casework.participants(conn, 6) == [ + {"user_id": "U1", "role": "subject"}, + {"user_id": "U2", "role": "reporter"}, + ] + + +def test_taking_somebody_off_writes_down_who_and_what_they_were(): + conn = Conn(one=("U1",)) + + assert casework.remove_participant(conn, 6, "U1", "subject", "UFD") is True + before = conn.did("INSERT INTO fd.audit")[0][5].obj + assert before == {"user_id": "U1", "role": "subject"} + + +def test_taking_off_somebody_who_is_not_there_changes_nothing(): + conn = Conn(one=None) + + assert casework.remove_participant(conn, 6, "U1", "subject", "UFD") is False + assert conn.did("INSERT INTO fd.audit") == [] + + +def test_no_query_asks_for_the_column_0112_dropped(): + asked = [casework.PARTICIPANTS, casework.ADD_PARTICIPANT, casework.REMOVE_PARTICIPANT] + assert [one for one in asked if DROPPED in one] == [] + + +def test_the_people_modal_sends_only_what_the_table_holds(): + picked = people.picked({"values": {people.WHO: {people.WHO: {"selected_users": ["U1"]}}}}) + assert set(picked) == {"user_ids", "role"} diff --git a/proxy/app.py b/proxy/app.py index 3d8810cd..3b376076 100644 --- a/proxy/app.py +++ b/proxy/app.py @@ -46,6 +46,7 @@ "admin": frozenset( { "admin.users.list", + "admin.roles.listAssignments", "search.messages", "conversations.history", "conversations.replies", @@ -65,6 +66,11 @@ "admin.analytics.getAvailableDateRange", } ), + "admin": frozenset( + { + "admin.roles.listAssignments", + } + ), } WRITE_METHODS = { @@ -86,7 +92,7 @@ NEMO_METHODS = {**WRITE_METHODS, **ACTIVITY_METHODS} WEB_METHODS["internal"] = WEB_METHODS["internal"] | ACTIVITY_METHODS["internal"] -WEB_METHODS["admin"] = HISTORY_METHODS["admin"] +WEB_METHODS["admin"] = WEB_METHODS["admin"] | HISTORY_METHODS["admin"] CREDENTIALS = ("internal", "admin") diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index fb3cb0e6..196f502f 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -48,6 +48,7 @@ @import "./mn/channels.css"; @import "./mn/message.css"; @import "./mn/you.css"; +@import "./mn/docs.css"; @import "./mn/fd.css"; @import "./mn/motion.css"; @import "./mn/responsive.css"; diff --git a/web/app/assets/tailwind/mn/controls.css b/web/app/assets/tailwind/mn/controls.css index cfa43e58..f2ce326d 100644 --- a/web/app/assets/tailwind/mn/controls.css +++ b/web/app/assets/tailwind/mn/controls.css @@ -16,6 +16,7 @@ letter-spacing: -0.008em; white-space: nowrap; text-decoration: none; + cursor: pointer; --ring: var(--line); --lift: 0 1px 1px var(--cast); box-shadow: 0 0 0 1px var(--ring), var(--lift); @@ -150,6 +151,7 @@ .iconbtn { display: inline-grid; place-items: center; + cursor: pointer; width: var(--ctl-h); height: var(--ctl-h); flex: none; @@ -165,6 +167,7 @@ border: 0; background: none; padding: 0; + cursor: pointer; color: var(--link); font-size: inherit; font-weight: 500; @@ -196,6 +199,10 @@ .btn-undo { color: var(--crit); } .btn-undo:hover { background: var(--crit-soft); color: var(--crit-deep); } + .rowact { color: var(--ink-3); font-weight: 500; } + .rowact:hover { color: var(--link); } + .rowact-crit:hover { color: var(--crit); } + .kbd { display: inline-flex; align-items: center; @@ -883,4 +890,130 @@ } .findbar:hover { border-color: var(--ink-4); color: var(--ink-2); } .findbar-text { flex: 1 1 auto; text-align: left; } + + .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(--crit-deep); + border-color: var(--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; + } + + .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; + } + + .num-in { + font-family: var(--font-code); + font-variant-numeric: tabular-nums; + max-width: 8rem; + } + + .rate-own { + color: var(--accent); + font-weight: 600; + } } diff --git a/web/app/assets/tailwind/mn/docs.css b/web/app/assets/tailwind/mn/docs.css new file mode 100644 index 00000000..9c0d44ca --- /dev/null +++ b/web/app/assets/tailwind/mn/docs.css @@ -0,0 +1,298 @@ +@layer base { + .doc { + --doc-measure: 72ch; + --doc-step: 48px; + + max-width: var(--doc-measure); + padding-bottom: calc(var(--s-xl) * 2); + font-size: var(--t-mid); + line-height: 1.75; + color: var(--ink-2); + text-wrap: pretty; + } + + .doc > * + * { margin-top: var(--s-sm); } + + .doc p { margin: 0; } + + .doc strong { color: var(--ink); font-weight: 600; } + + .doc a:not(.btn) { + color: var(--link); + text-decoration: underline; + text-decoration-thickness: from-font; + text-underline-offset: 3px; + text-decoration-color: color-mix(in oklch, var(--link) 35%, transparent); + transition: text-decoration-color var(--dur-2) var(--ease); + } + + .doc a:not(.btn):hover { text-decoration-color: var(--link); } + + .doc-spec { + display: flex; + flex-wrap: wrap; + gap: var(--s-sm) var(--s-lg); + margin: 0 0 var(--doc-step); + padding: var(--s-xs) var(--s-sm); + border: 1px solid var(--line); + border-radius: var(--r-card); + background: var(--sunk); + } + + .doc-spec > div { + display: grid; + gap: 2px; + flex: 0 1 auto; + } + + .doc-spec dt { + font-size: var(--t-mark); + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--ink-4); + } + + .doc-spec dd { + margin: 0; + font-family: var(--font-code); + font-size: var(--t-small); + font-variant-numeric: tabular-nums; + color: var(--ink); + white-space: nowrap; + } + + .doc-sec { scroll-margin-top: var(--s-md); } + + .doc-sec + .doc-sec { margin-top: var(--doc-step); } + + .doc-sec > h2 { + display: flex; + align-items: center; + gap: var(--s-2xs); + margin: 0 0 var(--s-xs); + font-size: 1.375rem; + font-weight: 600; + letter-spacing: -0.028em; + line-height: 1.2; + color: var(--ink); + scroll-margin-top: var(--s-md); + } + + .doc-sec > h3 { + margin: var(--s-lg) 0 var(--s-2xs); + font-size: var(--t-head); + font-weight: 600; + letter-spacing: -0.016em; + line-height: 1.3; + color: var(--ink); + } + + .doc-sec > * + p, + .doc-sec > * + .doc-defs, + .doc-sec > * + .pre, + .doc-sec > * + .doc-code, + .doc-sec > * + .doc-two, + .doc-sec > * + .doc-note { margin-top: var(--s-sm); } + + .doc-sec > h3 + * { margin-top: var(--s-2xs); } + + .doc-anchor { + display: inline-flex; + color: var(--ink-4); + opacity: 0; + text-decoration: none !important; + transition: opacity var(--dur-2) var(--ease), color var(--dur-2) var(--ease); + } + + .doc-sec > h2:hover .doc-anchor, + .doc-anchor:focus-visible { opacity: 1; } + + .doc-anchor:hover { color: var(--link); } + + .doc-lede { + font-size: var(--t-head); + line-height: 1.65; + color: var(--ink); + letter-spacing: -0.014em; + } + + .verb { + flex: none; + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: var(--r-hair); + background: var(--accent-soft); + color: var(--accent-deep); + font-family: var(--font-code); + font-size: var(--t-mark); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .doc-defs { display: grid; } + + .doc-def { + display: grid; + grid-template-columns: minmax(142px, 0.8fr) minmax(0, 2fr); + gap: 3px var(--s-md); + padding: 13px 0; + border-top: 1px solid var(--line-soft); + } + + .doc-def:last-child { border-bottom: 1px solid var(--line-soft); } + + .doc-def > dt { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + min-width: 0; + font-family: var(--font-code); + font-size: var(--t-body); + font-weight: 500; + color: var(--ink); + overflow-wrap: anywhere; + } + + .doc-def > dt em { + font-family: var(--font-sans); + font-style: normal; + font-size: var(--t-mark); + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--ink-4); + } + + .doc-def > dd { + margin: 0; + min-width: 0; + font-size: var(--t-body); + line-height: 1.65; + color: var(--ink-2); + } + + .doc-def > dd .mono { + font-family: var(--font-code); + color: var(--ink-3); + } + + .doc-code { + border: 1px solid var(--line); + border-radius: var(--r-card); + background: var(--sunk); + overflow: hidden; + } + + .doc-code-h { + display: flex; + align-items: center; + gap: var(--s-2xs); + padding: 6px var(--s-2xs) 6px var(--s-xs); + border-bottom: 1px solid var(--line-soft); + background: var(--card-2); + font-family: var(--font-code); + font-size: var(--t-fine); + color: var(--ink-3); + } + + .doc-code-h .grow { flex: 1 1 auto; min-width: 0; } + + .doc-copy { + flex: none; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 0; + border-radius: var(--r-tight); + background: none; + color: var(--ink-4); + cursor: pointer; + transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease); + } + + .doc-copy:hover { background: var(--hover); color: var(--ink-2); } + .doc-copy .doc-copy-on { display: none; color: var(--good); } + .doc-copy[data-copied] .doc-copy-off { display: none; } + .doc-copy[data-copied] .doc-copy-on { display: block; } + + .pre { + margin: 0; + padding: var(--s-xs); + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--r-card); + background: var(--sunk); + font-family: var(--font-code); + font-size: var(--t-small); + line-height: 1.7; + color: var(--ink); + tab-size: 2; + } + + .doc-code .pre { border: 0; border-radius: 0; background: transparent; } + + .pre .k { color: var(--accent); } + .pre .s { color: var(--good); } + .pre .c { color: var(--ink-4); font-style: italic; } + .pre .n { color: var(--warn); font-variant-numeric: tabular-nums; } + + .doc :not(.pre) > code { + font-family: var(--font-code); + font-size: 0.88em; + padding: 1px 5px; + border: 1px solid var(--line-soft); + border-radius: var(--r-hair); + background: var(--card-2); + color: var(--ink); + overflow-wrap: anywhere; + } + + .doc-note { + border-inline-start: 1px solid var(--accent-line); + padding-inline-start: var(--s-sm); + font-size: var(--t-body); + line-height: 1.7; + } + + .doc-note > b { + display: block; + margin-bottom: 1px; + font-size: var(--t-body); + font-weight: 600; + color: var(--ink); + } + + .doc-two { + display: grid; + gap: var(--s-xs); + grid-template-columns: repeat(auto-fit, minmax(min(272px, 100%), 1fr)); + } + + .doc-status { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: var(--r-hair); + background: var(--sunk); + color: var(--ink-2); + font-family: var(--font-code); + font-size: var(--t-mark); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + .doc-status.is-bad { background: var(--crit-soft); color: var(--crit); } + + @media (max-width: 760px) { + .doc { --doc-step: var(--s-lg); font-size: var(--t-body); } + .doc-sec > h2 { font-size: 1.1875rem; } + .doc-sec > h3 { font-size: var(--t-mid); } + .doc-def { grid-template-columns: minmax(0, 1fr); gap: 3px; } + } +} diff --git a/web/app/assets/tailwind/mn/overlays.css b/web/app/assets/tailwind/mn/overlays.css index a4679424..1f120254 100644 --- a/web/app/assets/tailwind/mn/overlays.css +++ b/web/app/assets/tailwind/mn/overlays.css @@ -612,4 +612,72 @@ border-radius: 3px; font-weight: 500; } + + .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; + } + + .crit-line { + display: flex; + align-items: flex-start; + gap: var(--s-2xs); + margin: 0; + padding: 10px 12px; + border-radius: var(--r-ctl); + background-color: var(--crit-soft); + color: var(--crit); + font-size: var(--t-small); + font-weight: 500; + line-height: 1.45; + } + + .crit-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; + } + + .secret-warn { + margin: 0; + font-size: var(--t-small); + color: var(--warn); + } } diff --git a/web/app/assets/tailwind/mn/surfaces.css b/web/app/assets/tailwind/mn/surfaces.css index 2751252c..dbe9790f 100644 --- a/web/app/assets/tailwind/mn/surfaces.css +++ b/web/app/assets/tailwind/mn/surfaces.css @@ -59,6 +59,8 @@ white-space: nowrap; } .card-head > .two-line { gap: 0; } + .card-head > .two-line.appline { gap: 3px; } + .card-head:has(> .appline) { min-height: 58px; padding-block: 14px; } .card-tools { margin-left: auto; display: flex; @@ -434,6 +436,10 @@ .row-v { font-weight: 500; color: var(--ink); } .row-note { font-size: var(--t-fine); color: var(--ink-4); } .rowbar { display: flex; align-items: center; gap: var(--s-2xs); padding: 8px var(--s-xs); border-bottom: 1px solid var(--line-soft); } + .keyline { gap: var(--s-xs); padding: 11px var(--s-xs); } + .keyline > .two-line > span { font-variant-numeric: tabular-nums; } + .keyline code { font-size: var(--t-small); color: var(--ink-3); } + .card > .keyline:last-child { border-bottom: 0; } .sep, .menu-sep { height: 1px; background: var(--line-soft); margin: 4px 0; border: 0; } .strip-div { width: 1px; height: 26px; background: var(--line-soft); flex: none; border: 0; } diff --git a/web/app/controllers/admin/api_settings_controller.rb b/web/app/controllers/admin/api_settings_controller.rb new file mode 100644 index 00000000..e2f5ec39 --- /dev/null +++ b/web/app/controllers/admin/api_settings_controller.rb @@ -0,0 +1,89 @@ +module Admin + class ApiSettingsController < BaseController + WINDOW = 30.days + MOST = 100_000 + + def show + @tokens = ::Api::Token.includes(:app).order(revoked_at: :asc, created_at: :desc).to_a + @apps = ::Api::App.live.count + @answering = ::Api::App.answering.count + @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 + @names = Fd::Names.for(@tokens.map(&:owner_user_id)) + end + + 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_account.user_id) + ::Api::Event.record!("setting_changed", actor: current_account.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_account.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_account.user_id) + ::Api::Event.record!("token_revoked", actor: current_account.user_id, + subject: token.shown, detail: "#{token.name}, owned by #{token.owner_user_id}") + end + + back_to "Revoked #{token.name}" + end + + private + + def writing + ActiveRecord::Base.transaction { yield } + end + + 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 admin_api_path, notice: said + end + + def refuse(said) + redirect_to admin_api_path, alert: said + end + end +end diff --git a/web/app/controllers/admin/flags_controller.rb b/web/app/controllers/admin/flags_controller.rb index 25a05301..f815dc5d 100644 --- a/web/app/controllers/admin/flags_controller.rb +++ b/web/app/controllers/admin/flags_controller.rb @@ -7,10 +7,10 @@ class FlagsController < BaseController }.freeze def show - @flags = Fd::Flag::KEYS.sort_by { |key| Fd::Flag.on?(key) ? 1 : 0 } - @dark = Fd::Flag::KEYS.reject { |key| Fd::Flag.on?(key) } - @losers = Fd::Flag::KEYS.index_with { |key| lose(key) } - @flipped = Fd::Flag.where(key: Fd::Flag::KEYS).index_by(&:key) + @flags = Fd::Flag::LISTED.sort_by { |key| Fd::Flag.on?(key) ? 1 : 0 } + @dark = Fd::Flag::LISTED.reject { |key| Fd::Flag.on?(key) } + @losers = Fd::Flag::LISTED.index_with { |key| lose(key) } + @flipped = Fd::Flag.where(key: Fd::Flag::LISTED).index_by(&:key) @names = Fd::Names.for(@flipped.values.filter_map(&:changed_by)) end 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..7dbf4618 --- /dev/null +++ b/web/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,93 @@ +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 :approved_for_scope + before_action :within_budget + + private + + def approved_for_scope + return if ::Api::Approval.held?(current_token.app_id) + + refuse(:forbidden, "not_approved") + end + + 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", + "not_approved" => "this app is not approved, ask for access" + }.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."], + [403, "not_approved", "The app behind this key is not approved."], + [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..bbbfb1d5 --- /dev/null +++ b/web/app/controllers/api/v1/channel_managers_controller.rb @@ -0,0 +1,50 @@ +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) + unless ::Api::Consent.granted?(user, current_token.app_id, CAPABILITY) + return withheld(channel, user) + end + + 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}/settings/permissions" } + 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..4ec2cffe --- /dev/null +++ b/web/app/controllers/api/v1/tokens_controller.rb @@ -0,0 +1,17 @@ +module Api + module V1 + class TokensController < BaseController + def show + render json: { + name: current_token.name, + prefix: current_token.prefix, + app: current_token.app.slug, + 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/application_controller.rb b/web/app/controllers/application_controller.rb index 6ff888c4..a3f084ed 100644 --- a/web/app/controllers/application_controller.rb +++ b/web/app/controllers/application_controller.rb @@ -12,8 +12,13 @@ def viewer_zone @viewer_zone ||= Community::Clock.known_zone(cookies[:mn_tz]) end + SETTINGS_PAGES = %w[accounts].freeze + def page_section return "admin" if controller_path.start_with?("admin/") + return "docs" if controller_path == "docs" + return "settings" if controller_path.start_with?("settings/") || + SETTINGS_PAGES.include?(controller_path) controller_path.start_with?("fd/") ? "fd" : "mn" end diff --git a/web/app/controllers/docs_controller.rb b/web/app/controllers/docs_controller.rb new file mode 100644 index 00000000..c4631c80 --- /dev/null +++ b/web/app/controllers/docs_controller.rb @@ -0,0 +1,12 @@ +class DocsController < ApplicationController + def index + redirect_to doc_path(Docs.first.slug) + end + + def show + @topic = Docs.find(params[:slug]) + return redirect_to docs_path if @topic.nil? + + @rate = Api::Setting.value("rate_per_minute") + end +end diff --git a/web/app/controllers/settings/appearances_controller.rb b/web/app/controllers/settings/appearances_controller.rb new file mode 100644 index 00000000..b2481adf --- /dev/null +++ b/web/app/controllers/settings/appearances_controller.rb @@ -0,0 +1,5 @@ +module Settings + class AppearancesController < BaseController + def show; end + end +end diff --git a/web/app/controllers/settings/apps_controller.rb b/web/app/controllers/settings/apps_controller.rb new file mode 100644 index 00000000..a5b231bb --- /dev/null +++ b/web/app/controllers/settings/apps_controller.rb @@ -0,0 +1,37 @@ +module Settings + class AppsController < BaseController + def create + return refuse(turned_off) if api_off? + + app = nil + ActiveRecord::Base.transaction do + app = Api::App.register!(member_id, name: params[:name], blurb: params[:blurb]) + Api::AccessRequest.ask!(app, reason: params[:reason]) + end + + redirect_to settings_keys_path, notice: "Asked for access for #{app.name}" + rescue Api::App::Unnamed + refuse("name the app") + rescue Api::App::TooThin + refuse("say what the app does, in a sentence") + rescue Api::AccessRequest::TooThin + refuse("say what the app needs access for") + end + + def destroy + app = Api::App.live.find_by(id: params[:id], owner_user_id: member_id) + return refuse("that app is not yours") if app.nil? + + app.retire!(by: member_id) + Api::Event.record!("app_retired", actor: member_id, subject: app.name) + + redirect_to settings_keys_path, notice: "Deleted #{app.name}" + end + + private + + def refuse(said) + redirect_to settings_keys_path, alert: said + end + end +end diff --git a/web/app/controllers/settings/base_controller.rb b/web/app/controllers/settings/base_controller.rb new file mode 100644 index 00000000..fd9ed47e --- /dev/null +++ b/web/app/controllers/settings/base_controller.rb @@ -0,0 +1,19 @@ +module Settings + class BaseController < ApplicationController + helper_method :member_id + + private + + def member_id + current_account.user_id + end + + def api_off? + Fd::Flag.off?(:public_api) + end + + def turned_off + "#{Fd::Flag.label(:public_api).downcase} is turned off" + end + end +end diff --git a/web/app/controllers/settings/consents_controller.rb b/web/app/controllers/settings/consents_controller.rb new file mode 100644 index 00000000..b66d5a4c --- /dev/null +++ b/web/app/controllers/settings/consents_controller.rb @@ -0,0 +1,38 @@ +module Settings + class ConsentsController < BaseController + def update + return refuse(turned_off) if api_off? + + key = params[:scope].to_s + Api::Scope.fetch(key) + app = app_asked + return refuse("no such app") if params[:app_id].present? && app.nil? + + granted = params[:on] == "1" + Api::Consent.set!(member_id, app&.id, key, granted, via: "dashboard") + + redirect_to settings_permissions_path, notice: said(app, key, granted) + rescue Api::Scope::Unknown => e + refuse(e.message) + end + + private + + def app_asked + return nil if params[:app_id].blank? + + found = Api::App.live.find_by(id: params[:app_id]) + found&.answering? ? found : nil + end + + def said(app, key, granted) + named = Api::Scope.label(key).downcase + who = app ? app.name : "Every app" + granted ? "#{who} may ask about #{named}" : "#{who} may no longer ask" + end + + def refuse(why) + redirect_to settings_permissions_path, alert: why + end + end +end diff --git a/web/app/controllers/settings/keys_controller.rb b/web/app/controllers/settings/keys_controller.rb new file mode 100644 index 00000000..e307fc5c --- /dev/null +++ b/web/app/controllers/settings/keys_controller.rb @@ -0,0 +1,26 @@ +module Settings + class KeysController < BaseController + def show + load_keys + end + + private + + def load_keys + @apps = Api::App.for_owner(member_id).to_a + @approved = Api::Approval.live.where(app_id: @apps.map(&:id)).pluck(:app_id).to_set + @waiting = Api::AccessRequest.pending.where(app_id: @apps.map(&:id)) + .index_by(&:app_id) + @tokens = Api::Token.where(app_id: @apps.map(&:id)) + .order(revoked_at: :asc, created_at: :desc).group_by(&:app_id) + @cap = Api::Setting.value("tokens_per_owner") + end + + def approved?(app) = @approved.include?(app.id) + + def waiting_for(app) = @waiting[app.id] + + def tokens_for(app) = @tokens.fetch(app.id, []) + helper_method :approved?, :waiting_for, :tokens_for + end +end diff --git a/web/app/controllers/settings/permissions_controller.rb b/web/app/controllers/settings/permissions_controller.rb new file mode 100644 index 00000000..3530bc0b --- /dev/null +++ b/web/app/controllers/settings/permissions_controller.rb @@ -0,0 +1,20 @@ +module Settings + class PermissionsController < BaseController + def show + @apps = Api::App.answering.to_a + @consents = Api::Consent.states_for(member_id) + @names = Fd::Names.for(@apps.map(&:owner_user_id).uniq) + end + + private + + def granted?(app, scope) + @consents[[app&.id, scope]] == Api::Consent::GRANTED + end + + def everywhere?(scope) = granted?(nil, scope) + + def covered?(app, scope) = everywhere?(scope) || granted?(app, scope) + helper_method :granted?, :everywhere?, :covered? + end +end diff --git a/web/app/controllers/settings/requests_controller.rb b/web/app/controllers/settings/requests_controller.rb new file mode 100644 index 00000000..37c4954e --- /dev/null +++ b/web/app/controllers/settings/requests_controller.rb @@ -0,0 +1,66 @@ +module Settings + class RequestsController < BaseController + before_action :require_reviewer, only: [:index, :settle] + + SHOWN = 50 + + RANK = { Api::AccessRequest::PENDING => 0, Api::AccessRequest::APPROVED => 1, + Api::AccessRequest::DECLINED => 2, Api::AccessRequest::WITHDRAWN => 3 }.freeze + + def index + waiting = Api::AccessRequest.queue.includes(:app).to_a + settled = Api::AccessRequest.settled.includes(:app) + .order(decided_at: :desc).limit(SHOWN).to_a + + @rows = waiting + settled.sort_by { |row| [RANK.fetch(row.state, 9), -row.decided_at.to_i] } + @waiting = waiting.size + @names = Fd::Names.for(named_in(@rows)) + end + + def create + app = Api::App.live.find_by(id: params[:app_id], owner_user_id: member_id) + return back("that app is not yours") if app.nil? + + Api::AccessRequest.ask!(app, reason: params[:reason]) + redirect_to settings_keys_path, notice: "Asked for access for #{app.name}" + rescue Api::AccessRequest::TooThin + back "say what the app needs access for" + rescue Api::AccessRequest::Already + back "that app has already asked" + end + + def withdraw + asked = Api::AccessRequest.pending.includes(:app).find_by(id: params[:id]) + return back("that request is not yours") if asked&.app&.owner_user_id != member_id + + asked.withdraw! + redirect_to settings_keys_path, notice: "Withdrawn" + end + + def settle + asked = Api::AccessRequest.pending.includes(:app).find_by(id: params[:id]) + return redirect_to(settings_requests_path, alert: "already settled") if asked.nil? + + yes = params[:verdict].to_s.casecmp?("approve") + asked.public_send(yes ? :approve! : :decline!, by: member_id, note: params[:note]) + said = yes ? "Approved" : "Declined" + redirect_to settings_requests_path, notice: "#{said} #{asked.app.name}" + end + + private + + def named_in(rows) + rows.flat_map { |row| [row.app.owner_user_id, row.decided_by] }.compact.uniq + end + + def back(said) + redirect_to settings_keys_path, alert: said + end + + def require_reviewer + return if Authz.holds?(current_account, "api.review") + + redirect_to settings_keys_path, alert: "reviewing api access is not yours to do" + end + end +end diff --git a/web/app/controllers/settings/tokens_controller.rb b/web/app/controllers/settings/tokens_controller.rb new file mode 100644 index 00000000..ce462886 --- /dev/null +++ b/web/app/controllers/settings/tokens_controller.rb @@ -0,0 +1,61 @@ +module Settings + class TokensController < KeysController + def create + return refuse(turned_off) if api_off? + + app = owned_app(params[:app_id]) + return refuse("that app is not yours") if app.nil? + return refuse("name what the key is for") if params[:name].to_s.strip.empty? + + token, @secret = Api::Token.mint!(app, params[:name], lasting: params[:lasting]) + Api::Event.record!("token_minted", actor: member_id, subject: token.shown, + detail: [app.name, token.name, + Api::Token::LIFE_WORDS.fetch(Api::Token.life_for(params[:lasting]))].join(", ")) + + @token = token + load_keys + render "settings/keys/show" + rescue Api::Token::NotApproved + refuse("that app has no approved scope yet") + rescue Api::Token::TooMany + refuse("that app already holds #{Api::Setting.value('tokens_per_owner')} keys") + end + + def rotate + return refuse(turned_off) if api_off? + + token = own_token + return refuse("that key is not yours") if token.nil? + + @secret = token.rotate!(by: member_id) + @token = token + load_keys + render "settings/keys/show" + end + + def destroy + token = own_token + return refuse("that key 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 settings_keys_path, notice: "Revoked #{token.name}" + end + + private + + def owned_app(id) + Api::App.live.find_by(id: id, owner_user_id: member_id) + end + + def own_token + Api::Token.live.find_by(id: params[:id], owner_user_id: member_id) + end + + def refuse(said) + redirect_to settings_keys_path, alert: said + end + end +end diff --git a/web/app/helpers/application_helper.rb b/web/app/helpers/application_helper.rb index 1c702d54..fcc6085f 100644 --- a/web/app/helpers/application_helper.rb +++ b/web/app/helpers/application_helper.rb @@ -165,7 +165,12 @@ def cachet_face_url(user_id) "admin" => ["M4 7h9", "M17 7h3", "M4 12h3", "M11 12h9", "M4 17h12", "M20 17h0", "M15 7a2 2 0 1 0 0-.01", "M9 12a2 2 0 1 0 0-.01", "M18 17a2 2 0 1 0 0-.01"], "news" => ["M4 5h11a1 1 0 0 1 1 1v12a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2z", - "M16 8h3a1 1 0 0 1 1 1v9a2 2 0 0 1-2 2", "M7 9h5", "M7 13h5", "M7 17h3"] + "M16 8h3a1 1 0 0 1 1 1v9a2 2 0 0 1-2 2", "M7 9h5", "M7 13h5", "M7 17h3"], + "gear" => ["M12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6", "M12 2v2.5", "M12 19.5V22", "M2 12h2.5", + "M19.5 12H22", "M4.9 4.9l1.8 1.8", "M17.3 17.3l1.8 1.8", "M19.1 4.9l-1.8 1.8", + "M6.7 17.3l-1.8 1.8"], + "book" => ["M4 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v15a2 2 0 0 0-2-1.6H6A2 2 0 0 1 4 16.8z", + "M20 5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v15a2 2 0 0 1 2-1.6h4a2 2 0 0 0 2-1.6z"] }.freeze def rail_icon(key) @@ -209,12 +214,22 @@ def here_stop(fire_engine:) end def section_pane + return "layouts/docs_pane" if page_section == "docs" + return "layouts/settings_pane" if page_section == "settings" return "layouts/admin_pane" if page_section == "admin" return nil unless on?(:analytics) return "layouts/engine_pane" if controller_name == "engine" return "layouts/community_pane" if - %w[home journey channels you accounts].include?(controller_name) + %w[home journey channels you].include?(controller_name) nil end + + def may_review_api? + Authz.holds?(current_account, "api.review") + end + + def api_queue_waiting + @api_queue_waiting ||= Api::AccessRequest.pending.count + end end diff --git a/web/app/helpers/docs_helper.rb b/web/app/helpers/docs_helper.rb new file mode 100644 index 00000000..10a8a0bf --- /dev/null +++ b/web/app/helpers/docs_helper.rb @@ -0,0 +1,35 @@ +module DocsHelper + def doc_anchor(id) + link_to "##{id}", class: "doc-anchor", aria: { label: "Link to this section" } do + tag.svg(width: 13, height: 13, viewBox: "0 0 24 24", fill: "none", + stroke: "currentColor", "stroke-width": 1.8, "stroke-linecap": "round", + "stroke-linejoin": "round", "aria-hidden": "true") do + safe_join([ + tag.path(d: "M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"), + tag.path(d: "M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7") + ]) + end + end + end + + def doc_copy + tag.button(type: "button", class: "doc-copy", data: { action: "copy#write" }, + aria: { label: "Copy" }) do + safe_join([ + tag.svg(class: "doc-copy-off", width: 13, height: 13, viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", "stroke-width": 1.7, + "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true") do + safe_join([ + tag.rect(x: 9, y: 9, width: 11, height: 11, rx: 2), + tag.path(d: "M5 15V5a2 2 0 0 1 2-2h8") + ]) + end, + tag.svg(class: "doc-copy-on", width: 13, height: 13, viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", "stroke-width": 2, + "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true") do + tag.path(d: "M5 13l4 4L19 7") + end + ]) + end + end +end diff --git a/web/app/helpers/fd_helper.rb b/web/app/helpers/fd_helper.rb index 5a653c47..46412888 100644 --- a/web/app/helpers/fd_helper.rb +++ b/web/app/helpers/fd_helper.rb @@ -147,7 +147,7 @@ def share_of(part, whole) end AUDIT_SOURCE = { "fire_engine" => "Fire Engine", "slack" => "Slack", - "read" => "Fire Engine" }.freeze + "read" => "Fire Engine", "api" => "Public API" }.freeze def audit_raw(row) JSON.pretty_generate(row.raw || row.detail || {}) @@ -1380,6 +1380,13 @@ def acted_label(at) "member/looked_up" => "Looked up", "report/received" => "Took a report on", "report/closed" => "Told the reporter on", + "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,", "grant/granted" => "Gave access to", "grant/revoked" => "Took access from", "permission/granted" => "Gave a role", @@ -1444,6 +1451,7 @@ def deed_head(deed) def deed_link(deed) case deed.kind when "case" then link_to deed.about, fd_case_path(deed.id), class: "lnk" + when "capability" then deed.about else member_link(deed.id) end end @@ -1452,6 +1460,62 @@ 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 key", + "batch_max" => "People per batch call", + "tokens_per_owner" => "Live keys per app" + }.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 + + ACCESS_CHIPS = { "approved" => "chip-good", "declined" => "chip-crit", + "withdrawn" => "chip-off", "pending" => "chip-warn" }.freeze + + def access_state_chip(state) + tag.span(state, class: "chip #{ACCESS_CHIPS.fetch(state, 'chip-off')}") + 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/javascript/controllers/copy_controller.js b/web/app/javascript/controllers/copy_controller.js index 26fc54f8..6d6e8b51 100644 --- a/web/app/javascript/controllers/copy_controller.js +++ b/web/app/javascript/controllers/copy_controller.js @@ -2,12 +2,17 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static values = { id: String } + static targets = ["source"] write(event) { event.preventDefault() if (!navigator.clipboard) return - navigator.clipboard.writeText(this.idValue).then(() => this.flash()) + navigator.clipboard.writeText(this.said()).then(() => this.flash()) + } + + said() { + return this.hasSourceTarget ? this.sourceTarget.innerText : this.idValue } flash() { diff --git a/web/app/javascript/controllers/docs_nav_controller.js b/web/app/javascript/controllers/docs_nav_controller.js new file mode 100644 index 00000000..0f35ef33 --- /dev/null +++ b/web/app/javascript/controllers/docs_nav_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + go(event) { + const link = event.target.closest("a[href^='#']") + if (!link || !this.element.contains(link)) return + + const id = link.getAttribute("href").slice(1) + const section = document.getElementById(id) + if (!section) return + + event.preventDefault() + section.scrollIntoView({ behavior: this.easing, block: "start" }) + history.replaceState(history.state, "", `#${id}`) + } + + get easing() { + return matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" + } +} diff --git a/web/app/models/api/access_request.rb b/web/app/models/api/access_request.rb new file mode 100644 index 00000000..44ef3394 --- /dev/null +++ b/web/app/models/api/access_request.rb @@ -0,0 +1,80 @@ +module Api + class AccessRequest < ApplicationRecord + self.table_name = "api.access_request" + + PENDING = "pending".freeze + APPROVED = "approved".freeze + DECLINED = "declined".freeze + WITHDRAWN = "withdrawn".freeze + + MAX_REASON = 500 + SHORTEST_REASON = 10 + + class Already < StandardError; end + class TooThin < StandardError; end + + belongs_to :app, class_name: "Api::App", foreign_key: :app_id, inverse_of: false + + scope :pending, -> { where(state: PENDING) } + scope :settled, -> { where.not(state: PENDING) } + + def self.open_for?(app_id) + pending.exists?(app_id: app_id) + end + + def self.for_owner(user_id) + where(app_id: App.where(owner_user_id: user_id).select(:id)).order(created_at: :desc) + end + + def self.queue + pending.where(app_id: App.live.select(:id)).order(created_at: :asc) + end + + def self.ask!(app, reason:) + raise Already if open_for?(app.id) || Approval.held?(app.id) + + said = reason.to_s.strip + raise TooThin if said.length < SHORTEST_REASON + + row = create!(app_id: app.id, reason: said.first(MAX_REASON)) + Event.record!("access_requested", actor: app.owner_user_id, subject: app.name, + detail: row.reason) + row + end + + def pending? = state == PENDING + + def approve!(by:, note: nil) + transaction do + settle!(APPROVED, by, note) + Approval.grant!(app_id, by: by) + Event.record!("access_approved", actor: by, subject: app.name, + detail: "for #{app.owner_user_id}") + end + self + end + + def decline!(by:, note: nil) + transaction do + settle!(DECLINED, by, note) + Event.record!("access_declined", actor: by, subject: app.name, + detail: "for #{app.owner_user_id}") + end + self + end + + def withdraw! + settle!(WITHDRAWN, app.owner_user_id, nil) + self + end + + private + + def settle!(to, by, note) + raise Already unless pending? + + update!(state: to, decided_by: by, decided_at: Time.current, + note: note.to_s.strip.presence) + end + end +end diff --git a/web/app/models/api/app.rb b/web/app/models/api/app.rb new file mode 100644 index 00000000..68e8c88e --- /dev/null +++ b/web/app/models/api/app.rb @@ -0,0 +1,57 @@ +module Api + class App < ApplicationRecord + self.table_name = "api.app" + + MAX_NAME = 60 + MAX_BLURB = 500 + SHORTEST_BLURB = 10 + + class TooThin < StandardError; end + class Unnamed < StandardError; end + + has_many :tokens, class_name: "Api::Token", foreign_key: :app_id, inverse_of: :app + has_one :approval, class_name: "Api::Approval", foreign_key: :app_id, inverse_of: :app + + scope :live, -> { where(retired_at: nil) } + + def self.for_owner(user_id) + live.where(owner_user_id: user_id).order(:name) + end + + def self.answering + live.where(id: Approval.live.select(:app_id)).order(:name) + end + + def self.register!(owner_user_id, name:, blurb:) + said = name.to_s.strip + covers = blurb.to_s.strip + raise Unnamed if said.empty? + raise TooThin if covers.length < SHORTEST_BLURB + + create!(owner_user_id: owner_user_id, name: said.first(MAX_NAME), + blurb: covers.first(MAX_BLURB), slug: slug_for(said)) + end + + def self.slug_for(name) + base = name.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-") + base = "app" if base.empty? + taken = where("slug LIKE ?", "#{base}%").pluck(:slug) + return base unless taken.include?(base) + + (2..).each { |n| return "#{base}-#{n}" unless taken.include?("#{base}-#{n}") } + end + + def answering? = Approval.held?(id) + + def retired? = retired_at.present? + + def retire!(by:) + transaction do + update!(retired_at: Time.current) + AccessRequest.pending.where(app_id: id).each(&:withdraw!) + Approval.revoke!(id, by: by) + tokens.live.find_each { |token| token.revoke!(by: by) } + end + end + end +end diff --git a/web/app/models/api/approval.rb b/web/app/models/api/approval.rb new file mode 100644 index 00000000..b8b155d1 --- /dev/null +++ b/web/app/models/api/approval.rb @@ -0,0 +1,28 @@ +module Api + class Approval < ApplicationRecord + self.table_name = "api.approval" + self.primary_key = "app_id" + + belongs_to :app, class_name: "Api::App", foreign_key: :app_id, inverse_of: :approval + + scope :live, -> { where(revoked_at: nil) } + + def self.held?(app_id) + live.exists?(app_id: app_id) + end + + def self.grant!(app_id, by:) + row = find_or_initialize_by(app_id: app_id) + row.update!(granted_by: by, granted_at: Time.current, revoked_by: nil, revoked_at: nil) + row + end + + def self.revoke!(app_id, by:) + row = live.find_by(app_id: app_id) + return nil if row.nil? + + row.update!(revoked_by: by, revoked_at: Time.current) + row + end + 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..133e1223 --- /dev/null +++ b/web/app/models/api/consent.rb @@ -0,0 +1,49 @@ +module Api + class Consent < ApplicationRecord + self.table_name = "api.consent" + + GRANTED = "granted".freeze + WITHHELD = "withheld".freeze + + def self.granted?(user_id, app_id, capability) + where(user_id: user_id, capability: capability, state: GRANTED) + .where("app_id = ? OR app_id IS NULL", app_id) + .exists? + end + + def self.everywhere?(user_id, capability) + exists?(user_id: user_id, app_id: nil, capability: capability, state: GRANTED) + end + + def self.states_for(user_id) + where(user_id: user_id).pluck(:app_id, :capability, :state) + .to_h { |app_id, capability, state| [[app_id, capability], state] } + end + + def self.granted_count(user_id) + where(user_id: user_id, state: GRANTED).count + end + + def self.set!(user_id, app_id, capability, granted, via:) + state = granted ? GRANTED : WITHHELD + now = Time.current + + transaction do + held = where(user_id: user_id, capability: capability) + held = app_id.nil? ? held.where(app_id: nil) : held.where(app_id: app_id) + row = held.take + + if row + row.update!(state: state, changed_at: now, changed_via: via, + first_granted_at: row.first_granted_at || (now if granted)) + else + create!(user_id: user_id, app_id: app_id, capability: capability, state: state, + changed_at: now, changed_via: via, first_granted_at: (now if granted)) + end + + ConsentLog.create!(user_id: user_id, app_id: app_id, capability: capability, + state: state, via: via) + 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/scope.rb b/web/app/models/api/scope.rb new file mode 100644 index 00000000..936725bb --- /dev/null +++ b/web/app/models/api/scope.rb @@ -0,0 +1,21 @@ +module Api + class Scope + class Unknown < ArgumentError; end + + TABLE = YAML.load_file(Rails.root.join("../db/api_scopes.yml")) + .fetch("scopes").freeze + KEYS = TABLE.keys.freeze + + def self.fetch(key) + TABLE.fetch(key.to_s) { raise Unknown, "#{key} is not a scope" } + 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/setting.rb b/web/app/models/api/setting.rb new file mode 100644 index 00000000..f105cb52 --- /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" => 1 + }.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..fe70b48e --- /dev/null +++ b/web/app/models/api/token.rb @@ -0,0 +1,120 @@ +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 + class NotApproved < StandardError; end + + belongs_to :app, class_name: "Api::App", foreign_key: :app_id, inverse_of: :tokens + + 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.for_app(app_id) + where(app_id: app_id).order(revoked_at: :asc, created_at: :desc) + end + + def self.room_for?(app_id) + live.where(app_id: app_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!(app, name, lasting: DEFAULT_LIFE) + raise NotApproved unless Approval.held?(app.id) + raise TooMany unless room_for?(app.id) + + key = secret + row = create!(app_id: app.id, owner_user_id: app.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 rotate!(by:) + key = self.class.secret + update!(prefix: key.first(LEAD.length + SHOWN), digest: self.class.digest_of(key), + rotated_at: Time.current) + Event.record!("token_rotated", actor: by, subject: shown, detail: name) + key + 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..88c945b2 --- /dev/null +++ b/web/app/models/docs.rb @@ -0,0 +1,30 @@ +class Docs + Section = Struct.new(:id, :title, keyword_init: true) + Topic = Struct.new(:slug, :title, :group, :sections, keyword_init: true) + + CHANNEL_MANAGERS = Topic.new( + slug: "channel-managers", + title: "Channel managers", + group: "Public API", + sections: [ + Section.new(id: "access", title: "Access"), + 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.groups = ALL.group_by(&:group) + + def self.first = ALL.first + + def self.find(slug) = ALL.find { |topic| topic.slug == slug.to_s } + + def self.section_ids = ALL.flat_map { |topic| topic.sections.map(&:id) } +end diff --git a/web/app/models/fd/audit_query.rb b/web/app/models/fd/audit_query.rb index 37c12c2c..6be2cd2a 100644 --- a/web/app/models/fd/audit_query.rb +++ b/web/app/models/fd/audit_query.rb @@ -12,6 +12,8 @@ def slack? = source == SLACK def read? = source == READ + def api? = source == API + def changed_keys ((after || {}).keys | (before || {}).keys).sort end @@ -42,7 +44,8 @@ def app_name = (detail || {}).dig("app", "name") FIRE_ENGINE = "fire_engine".freeze SLACK = "slack".freeze READ = "read".freeze - SOURCES = [FIRE_ENGINE, SLACK, READ].freeze + API = "api".freeze + SOURCES = [FIRE_ENGINE, SLACK, READ, API].freeze LIMIT = 100 MEMBER_ID = /\A[UW][A-Z0-9]{2,}\z/ @@ -53,6 +56,7 @@ def app_name = (detail || {}).dig("app", "name") "engine" => "Fire Engine", "slack" => "Slack", "reads" => "Identity reads", + "api" => "Public API", "refusals" => "Refusals" }.freeze @@ -63,6 +67,7 @@ def app_name = (detail || {}).dig("app", "name") "engine" => [FIRE_ENGINE], "slack" => [SLACK], "reads" => [READ], + "api" => [API], "refusals" => [FIRE_ENGINE] }.freeze @@ -300,6 +305,89 @@ def refusals? = view == "refusals" READ_WHERE SQL + API_EVENT_BRANCH = <<~SQL.freeze + SELECT 'api' AS source, 'ae' || e.id::text AS id, e.at AS at, + e.actor_user_id AS actor_id, 'human'::text AS actor_kind, + 'api/' || e.verb AS verb, + 'api'::text AS entity_kind, e.subject AS entity_id, + NULL::text AS entity_ref, NULL::text AS subject_id, + NULL::jsonb AS before, NULL::jsonb AS after, + jsonb_strip_nulls(jsonb_build_object('said', e.detail)) AS detail + FROM api.event_log e + WHERE e.at >= :since + API_EVENT_WHERE + SQL + + API_CONSENT_BRANCH = <<~SQL.freeze + SELECT 'api' AS source, 'ac' || c.id::text AS id, c.at AS at, + c.user_id AS actor_id, 'human'::text AS actor_kind, + 'consent/' || c.state AS verb, + 'scope'::text AS entity_kind, c.capability AS entity_id, + NULL::text AS entity_ref, c.user_id AS subject_id, + NULL::jsonb AS before, NULL::jsonb AS after, + jsonb_build_object('via', c.via) AS detail + FROM api.consent_log c + WHERE c.at >= :since + API_CONSENT_WHERE + SQL + + API_REQUEST_BRANCH = <<~SQL.freeze + SELECT 'api' AS source, 'ar' || r.id::text AS id, r.at AS at, + t.owner_user_id AS actor_id, 'app'::text AS actor_kind, + 'api/checked'::text AS verb, + 'channel'::text AS entity_kind, r.channel_id AS entity_id, + r.channel_id AS entity_ref, r.subject_user_id AS subject_id, + NULL::jsonb AS before, NULL::jsonb AS after, + jsonb_build_object('outcome', r.outcome, 'token', t.prefix) AS detail + FROM api.request_log r + JOIN api.token t ON t.id = r.token_id + WHERE r.at >= :since + API_REQUEST_WHERE + SQL + + def api_where(at:, actor:, subject:, verb:, kind:, channel: nil) + parts = [] + parts << "AND #{actor} IN (:actors)" if search.of("actor").any? + parts << "AND #{subject} IN (:subjects)" if search.of("about").any? + parts << "AND #{verb} IN (:actions)" if search.of("action").any? + if search.of("channel").any? + parts << (channel ? "AND #{channel} IN (:channels)" : "AND false") + end + parts << "AND false" if search.text? + parts << "AND false" if search.of("ip").any? + if looked_up? && search.of("ip").empty? + parts << (anybody.any? ? "AND (#{actor} IN (:anybody) OR " \ + "#{subject} IN (:anybody))" : "AND false") + end + parts << api_doer(actor, kind) + parts << "AND #{at} >= :after" if search.one("after") + parts << "AND #{at} < :before" if search.one("before") + parts.compact.join("\n") + end + + def api_doer(actor, kind) + case search.one("is") + when "human" then kind == "human" ? nil : "AND false" + when "nemo" then "AND false" + when "nobody" then "AND #{actor} IS NULL" + end + end + + def api_event_where + api_where(at: "e.at", actor: "e.actor_user_id", subject: "NULL::text", + verb: "('api/' || e.verb)", kind: "human") + end + + def api_consent_where + api_where(at: "c.at", actor: "c.user_id", subject: "c.user_id", + verb: "('consent/' || c.state)", kind: "human") + end + + def api_request_where + api_where(at: "r.at", actor: "t.owner_user_id", subject: "r.subject_user_id", + verb: "'api/checked'", kind: "app", channel: "r.channel_id") + end + def anybody @anybody ||= (search.people_for_email + search.people_for_ip).uniq end @@ -405,6 +493,11 @@ def branches end held << SLACK_BRANCH.sub("SLACK_WHERE", slack_where) if wanted_sources.include?(SLACK) held << READ_BRANCH.sub("READ_WHERE", read_where) if wanted_sources.include?(READ) + if wanted_sources.include?(API) + held << API_EVENT_BRANCH.sub("API_EVENT_WHERE", api_event_where) + held << API_CONSENT_BRANCH.sub("API_CONSENT_WHERE", api_consent_where) + held << API_REQUEST_BRANCH.sub("API_REQUEST_WHERE", api_request_where) + end held end @@ -481,6 +574,7 @@ def ask(sql) count(*) FILTER (WHERE source = 'fire_engine') AS engine, count(*) FILTER (WHERE source = 'slack') AS slack, count(*) FILTER (WHERE source = 'read') AS reads, + count(*) FILTER (WHERE source = 'api') AS api, 0 AS refusals FROM held SQL @@ -513,7 +607,8 @@ def pick(source, id) branch = case source when FIRE_ENGINE then ENGINE_BRANCH.sub("ENGINE_WHERE", "AND a.id::text = :id") when SLACK then SLACK_BRANCH.sub("SLACK_WHERE", "AND e.id = :id") - else READ_BRANCH.sub("READ_WHERE", "AND 'r' || l.id::text = :id") + when READ then READ_BRANCH.sub("READ_WHERE", "AND 'r' || l.id::text = :id") + else api_branch_for(id) end found = ApplicationRecord.connection.select_all( ApplicationRecord.sanitize_sql([branch, binds.merge(id: id, since: Time.zone.at(0))]) @@ -521,6 +616,14 @@ def pick(source, id) found && build(found) end + def api_branch_for(id) + case id.to_s[0, 2] + when "ae" then API_EVENT_BRANCH.sub("API_EVENT_WHERE", "AND 'ae' || e.id::text = :id") + when "ac" then API_CONSENT_BRANCH.sub("API_CONSENT_WHERE", "AND 'ac' || c.id::text = :id") + else API_REQUEST_BRANCH.sub("API_REQUEST_WHERE", "AND 'ar' || r.id::text = :id") + end + end + def build(row) Row.new( source: row["source"], id: row["id"], at: row["at"], actor_id: row["actor_id"], diff --git a/web/app/models/fd/flag.rb b/web/app/models/fd/flag.rb index 73298a8e..db5f40f6 100644 --- a/web/app/models/fd/flag.rb +++ b/web/app/models/fd/flag.rb @@ -15,8 +15,14 @@ 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.listed?(key) = fetch(key)["listed"] != false + + LISTED = KEYS.select { |key| listed?(key) }.freeze + def self.flipped pluck(:key, :is_on).to_h end 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/accounts/show.html.erb b/web/app/views/accounts/show.html.erb index 0b3aa3d1..261ca393 100644 --- a/web/app/views/accounts/show.html.erb +++ b/web/app/views/accounts/show.html.erb @@ -46,20 +46,10 @@ -
-
-
- Theme - - - -
- <%= render "accounts/theme" %> -
- +
- What you did + Activity <% if @deed_count.positive? %> diff --git a/web/app/views/admin/api_settings/_modals.html.erb b/web/app/views/admin/api_settings/_modals.html.erb new file mode 100644 index 00000000..f3eecf55 --- /dev/null +++ b/web/app/views/admin/api_settings/_modals.html.erb @@ -0,0 +1,61 @@ +<% @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: admin_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: admin_api_token_rate_path(token), method: :patch, + html: { autocomplete: "off" } do %> + + + <% end %> + <% end %> +<% end %> diff --git a/web/app/views/admin/api_settings/show.html.erb b/web/app/views/admin/api_settings/show.html.erb new file mode 100644 index 00000000..cee1dcbd --- /dev/null +++ b/web/app/views/admin/api_settings/show.html.erb @@ -0,0 +1,124 @@ +<% content_for :page_title, "Public API" %> + +
+ <%= render "shared/stat", k: "Opted in", v: number_with_delimiter(@opted_in), + say: pluralize(Api::Scope::KEYS.size, "scope") %> + <%= render "shared/stat", k: "Apps", v: @apps, + say: "#{@answering} answering" %> + <%= render "shared/stat", k: "Live keys", v: @tokens.count { |one| !one.spent? }, + say: "across #{pluralize(@tokens.map(&:app_id).uniq.size, 'app')}" %> + <%= render "shared/stat", k: "Checks, 30d", v: number_with_delimiter(@checks), + say: pluralize(@channels, "channel") %> + <%= render "shared/stat", k: "Withheld", v: number_with_delimiter(@withheld), + say: withheld_share(@withheld, @checks) %> +
+ +
+
+
+

Configuration

+
+ +
+
+ <%= Fd::Flag.label(:public_api) %> + + <%= api_state_chip %> + <%= flag_switch(:public_api) %> + +
+ <% @dials.each do |key, value| %> +
+ <%= dial_label(key) %> + + <%= value %> + <% if may_grant? %> + + <% end %> + +
+ <% end %> +
+ Last sync + "><%= synced_line(@synced) %> +
+
+ Last changed + "><%= dial_change(@last_dial) %> +
+
+
+ +
+
+

Tokens

+
+ + <%= @tokens.count { |one| !one.spent? } %> live · + <%= @tokens.count(&:spent?) %> spent + +
+
+ + <% if @tokens.any? %> +
+ + + + + + + + + + + + + + + <% @tokens.each do |token| %> + + + + + + + + + + + <% end %> + +
appkey namekeyrateexpiresasks 30dlast used
+ + <%= face(token.owner_user_id) %> + + <%= token.app.name %> + <%= 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") %> + + <% elsif may_grant? %> + + <% end %> +
+
+ <% else %> +
+

No tokens

+
+ <% end %> +
+
+ +<% if may_grant? %> + <%= render "admin/api_settings/modals" %> +<% 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..45adca6b --- /dev/null +++ b/web/app/views/docs/_channel_managers.html.erb @@ -0,0 +1,244 @@ +<% channel_pattern = Api::V1::ChannelManagersController::CHANNEL.source %> +<% member_pattern = Api::V1::ChannelManagersController::MEMBER.source %> + +
+
Base
<%= request.base_url %>/api/v1
+
Scheme
Bearer
+
Rate
<%= @rate %>/min
+
Format
application/json
+
+ +
+

Access <%= doc_anchor("access") %>

+ +

+ Keys belong to an app, not to you. Register one on + <%= link_to "your settings page", settings_keys_path %> with a name and what it does, + and ask for the scopes it needs. A reviewer approves or declines each scope. +

+ +

+ The name and description are what members see when they decide whether to answer you, + so write them for a member, not for the reviewer. +

+ +
+
+
pending state
+
Waiting on a reviewer. Withdraw it at any time.
+
+
+
approved state
+
The app may hold keys for that scope. Rechecked on every call.
+
+
+
declined state
+
Nothing is granted. Ask again with a fuller reason.
+
+
+ +
+ Revocation + An approval taken back stops the app's live keys answering straight away, with + 403 not_approved. Retiring an app revokes every key it held. +
+
+ +
+

Authentication <%= doc_anchor("auth") %>

+ +

+ Keys are nemo_live_ plus 20 base58 characters, generated once a scope is + approved and stored as a SHA-256 digest. Shown once. Rotating one replaces the secret and + keeps the name, the rate and the audit trail. Never accepted as a query parameter. +

+ +
+
+ header + <%= doc_copy %> +
+
Authorization: Bearer nemo_live_7Fj2QcW8xR4mLpVdA1sT
+
+ +
+
+ GET + /api/v1/token + <%= doc_copy %> +
+
{ "name": "Toolbox", "prefix": "nemo_live_7Fj2",
+  "app": "triage-bot", "rate_per_minute": <%= @rate %> }
+
+
+ +
+

GET Check a member <%= doc_anchor("check") %>

+ +
+
+ path + <%= doc_copy %> +
+
/api/v1/channels/{channel_id}/managers/{user_id}
+
+ +

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

+ +

Parameters

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

Responses

+
+
+
+ 200 + granted + <%= doc_copy %> +
+
{
+  "channel_id": "C0P5NE3M0",
+  "user_id": "U0A5PLKMB25",
+  "consent": "granted",
+  "is_manager": true,
+  "synced_at": "2026-08-31T04:12:07Z",
+  "stale": false
+}
+
+
+
+ 200 + withheld + <%= doc_copy %> +
+
{
+  "channel_id": "C0P5NE3M0",
+  "user_id": "U0A5PLKMB25",
+  "consent": "withheld",
+  "is_manager": null,
+  "opt_in_url": "<%= settings_permissions_url %>"
+}
+
+
+ +

Fields

+
+
+
consent string
+
granted or withheld. Always present.
+
+
+
is_manager bool | null
+
null whenever consent is withheld.
+
+
+
synced_at iso8601?
+
When the channel was last read from Slack. Absent when withheld.
+
+
+
stale bool?
+
true past the cache TTL. Answer still served.
+
+
+
opt_in_url string?
+
Present only when withheld.
+
+
+
+ + + +
+

Rate limits <%= doc_anchor("rate") %>

+ +

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

+ +
+
+ response headers + <%= doc_copy %> +
+
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 <%= doc_anchor("errors") %>

+ +
+
+ 429 + body + <%= doc_copy %> +
+
{ "error": "rate_limited", "message": "rate limit spent, see retry_after", "retry_after": 34 }
+
+ +
+ <% Api::V1::BaseController::CALLER_ERRORS.each do |status, key, said| %> +
+
<%= key %> = 500 %>"><%= status %>
+
<%= said %>
+
+ <% end %> +
+
diff --git a/web/app/views/docs/show.html.erb b/web/app/views/docs/show.html.erb new file mode 100644 index 00000000..b154b534 --- /dev/null +++ b/web/app/views/docs/show.html.erb @@ -0,0 +1,8 @@ +<% content_for :page_title, @topic.title %> +<% content_for :head_actions do %> + <%= link_to "API keys", settings_keys_path, class: "btn" %> +<% end %> + +
+ <%= render "docs/#{@topic.slug.tr('-', '_')}", topic: @topic %> +
diff --git a/web/app/views/layouts/_admin_pane.html.erb b/web/app/views/layouts/_admin_pane.html.erb index 8443a284..7820f096 100644 --- a/web/app/views/layouts/_admin_pane.html.erb +++ b/web/app/views/layouts/_admin_pane.html.erb @@ -41,6 +41,10 @@ Settings <% end %> + <%= link_to admin_api_path, aria: { current: ("page" if controller_path == "admin/api_settings") } do %> + + Public API + <% end %>
diff --git a/web/app/views/layouts/_docs_pane.html.erb b/web/app/views/layouts/_docs_pane.html.erb new file mode 100644 index 00000000..343540b7 --- /dev/null +++ b/web/app/views/layouts/_docs_pane.html.erb @@ -0,0 +1,28 @@ + diff --git a/web/app/views/layouts/_rail.html.erb b/web/app/views/layouts/_rail.html.erb index 47490e43..c209af12 100644 --- a/web/app/views/layouts/_rail.html.erb +++ b/web/app/views/layouts/_rail.html.erb @@ -18,6 +18,19 @@
+ <%= link_to docs_path, class: "rail-btn", title: "Docs", + aria: { current: ("page" if page_section == "docs"), label: "Docs" } do %> + <%= rail_icon("book") %> + Docs + <% end %> + <%= link_to account_path, class: "rail-btn", title: "Settings", + aria: { current: ("page" if page_section == "settings"), label: "Settings" } do %> + <%= rail_icon("gear") %> + Settings + <% if may_review_api? && api_queue_waiting.positive? %> + <%= api_queue_waiting > 99 ? "99+" : api_queue_waiting %> + <% end %> + <% end %> <%= render "layouts/you", staff: current_account %>
diff --git a/web/app/views/layouts/_settings_pane.html.erb b/web/app/views/layouts/_settings_pane.html.erb new file mode 100644 index 00000000..a26ec5a7 --- /dev/null +++ b/web/app/views/layouts/_settings_pane.html.erb @@ -0,0 +1,43 @@ + diff --git a/web/app/views/settings/appearances/show.html.erb b/web/app/views/settings/appearances/show.html.erb new file mode 100644 index 00000000..af789692 --- /dev/null +++ b/web/app/views/settings/appearances/show.html.erb @@ -0,0 +1,13 @@ +<% content_for :page_title, "Appearance" %> + +
+
+
+ Theme + + + +
+ <%= render "accounts/theme" %> +
+
diff --git a/web/app/views/settings/keys/_ask.html.erb b/web/app/views/settings/keys/_ask.html.erb new file mode 100644 index 00000000..262ac4a2 --- /dev/null +++ b/web/app/views/settings/keys/_ask.html.erb @@ -0,0 +1,18 @@ +<%= render layout: "fd/modal", locals: { id: "ask-#{app.id}", + title: "Ask for access", sub: app.name } do %> + <%= form_with url: settings_requests_path, method: :post, html: { autocomplete: "off" } do %> + <%= hidden_field_tag :app_id, app.id, id: nil %> + + + <% end %> +<% end %> diff --git a/web/app/views/settings/keys/_kill.html.erb b/web/app/views/settings/keys/_kill.html.erb new file mode 100644 index 00000000..e3718c20 --- /dev/null +++ b/web/app/views/settings/keys/_kill.html.erb @@ -0,0 +1,20 @@ +<%= render layout: "fd/modal", locals: { id: "kill-#{token.id}", + title: "Revoke #{token.name}?" } do %> + <%= form_with url: settings_token_path(token), method: :delete do %> + + + <% end %> +<% end %> diff --git a/web/app/views/settings/keys/_mint.html.erb b/web/app/views/settings/keys/_mint.html.erb new file mode 100644 index 00000000..832bb675 --- /dev/null +++ b/web/app/views/settings/keys/_mint.html.erb @@ -0,0 +1,42 @@ +<%= render layout: "fd/modal", locals: { id: "mint-#{app.id}", + title: @secret.present? ? "Your key" : "New key", sub: app.name, + open: @secret.present? && @token&.app_id == app.id } do %> + <% if @secret.present? %> + + + <% else %> + <%= form_with url: settings_tokens_path, method: :post, + data: { turbo: false }, html: { autocomplete: "off" } do %> + <%= hidden_field_tag :app_id, app.id, id: nil %> + + + <% end %> + <% end %> +<% end %> diff --git a/web/app/views/settings/keys/_register.html.erb b/web/app/views/settings/keys/_register.html.erb new file mode 100644 index 00000000..54e75960 --- /dev/null +++ b/web/app/views/settings/keys/_register.html.erb @@ -0,0 +1,26 @@ +<%= render layout: "fd/modal", locals: { id: "new-app", title: "Register an app" } do %> + <%= form_with url: settings_apps_path, method: :post, html: { autocomplete: "off" } do %> + + + <% end %> +<% end %> diff --git a/web/app/views/settings/keys/_retire.html.erb b/web/app/views/settings/keys/_retire.html.erb new file mode 100644 index 00000000..dd7f0a53 --- /dev/null +++ b/web/app/views/settings/keys/_retire.html.erb @@ -0,0 +1,20 @@ +<%= render layout: "fd/modal", locals: { id: "retire-#{app.id}", + title: "Delete #{app.name}?" } do %> + <%= form_with url: settings_app_path(app), method: :delete do %> + + + <% end %> +<% end %> diff --git a/web/app/views/settings/keys/show.html.erb b/web/app/views/settings/keys/show.html.erb new file mode 100644 index 00000000..e671950d --- /dev/null +++ b/web/app/views/settings/keys/show.html.erb @@ -0,0 +1,99 @@ +<% content_for :page_title, "API keys" %> +<% api_off = Fd::Flag.off?(:public_api) %> +<% content_for :head_actions do %> + <% unless api_off %> + + <% end %> +<% end %> + +<% if @apps.any? %> +
+ <% @apps.each do |app| %> + <% live_app = approved?(app) %> + <% asked = waiting_for(app) %> + <% tokens = tokens_for(app) %> + <% live = tokens.reject(&:spent?) %> +
+
+
+

<%= app.name %>

+ <%= app.blurb %> +
+ +
+ <% if asked %> + Pending + <% elsif !live_app && !api_off %> + + <% end %> + + <%= render layout: "shared/dropdown", + locals: { label: "More for #{app.name}", trigger: "\u2026", + trigger_css: "iconbtn" } do %> + <% if asked %> + <%= button_to "Withdraw the request", withdraw_settings_request_path(asked), + method: :delete, class: "btn", form: { class: "contents" } %> + + <% end %> + + <% end %> +
+
+ + <% live.each do |token| %> +
+ + <%= token.name %> + + <%= token.shown %> · + <%= token.rate %>/min · <%= token.dies_in %> · + <%= token.last_used_at ? "used #{time_ago_in_words(token.last_used_at)} ago" : "never used" %> + + + + <% unless api_off %> + <%= button_to "Rotate", rotate_settings_token_path(token), method: :post, + class: "text-btn rowact", + form: { class: "contents", data: { turbo: false } } %> + + <% end %> +
+ <% end %> + + <% if live_app && live.empty? %> +
+ No key yet + + <% unless api_off %> + + <% end %> +
+ <% end %> +
+ <% end %> +
+<% else %> +
+

No apps

+
+<% end %> + +<% unless api_off %> + <%= render "settings/keys/register" %> + <% @apps.each do |app| %> + <%= render "settings/keys/mint", app: app %> + <%= render "settings/keys/retire", app: app %> + <% if !approved?(app) && waiting_for(app).nil? %> + <%= render "settings/keys/ask", app: app %> + <% end %> + <% tokens_for(app).reject(&:spent?).each do |token| %> + <%= render "settings/keys/kill", token: token %> + <% end %> + <% end %> +<% end %> diff --git a/web/app/views/settings/permissions/show.html.erb b/web/app/views/settings/permissions/show.html.erb new file mode 100644 index 00000000..a6feb044 --- /dev/null +++ b/web/app/views/settings/permissions/show.html.erb @@ -0,0 +1,105 @@ +<% content_for :page_title, "Permissions" %> +<% api_off = Fd::Flag.off?(:public_api) %> + +
+
+

All apps

+
+ +
+
+ + + + + + + + + <% Api::Scope::KEYS.each do |key| %> + <% on = everywhere?(key) %> + + + + + <% end %> + +
permission
+ + <%= Api::Scope.label(key) %> + <%= Api::Scope.covers(key) %> + + + + <%= button_to on ? "Opt out" : "Opt in", + settings_consent_path(scope: key, on: on ? "0" : "1"), + method: :patch, disabled: api_off, + class: "btn btn-sm #{'btn-primary' unless on}", + form: { class: "contents" } %> + +
+
+
+
+ +<% if @apps.any? %> +
+
+

Per-app

+
+ +
+ <% @apps.each do |app| %> + <% on = Api::Scope::KEYS.count { |key| covered?(app, key) } %> +
+ + <%= app.name %> + by <%= @names[app.owner_user_id] %> + + <% if on.positive? %> + <%= on %> of <%= Api::Scope::KEYS.size %> + <% end %> + + +
+ + + + + + + + + <% Api::Scope::KEYS.each do |key| %> + <% all = everywhere?(key) %> + <% mine = granted?(app, key) %> + + + + + <% end %> + +
permission
+ <%= Api::Scope.label(key) %> + + + <% if all %> + All apps + <% else %> + <%= button_to mine ? "Opt out" : "Opt in", + settings_consent_path(app_id: app.id, scope: key, + on: mine ? "0" : "1"), + method: :patch, disabled: api_off, + class: "btn btn-sm #{'btn-primary' unless mine}", + form: { class: "contents" } %> + <% end %> + +
+
+
+ <% end %> +
+
+<% end %> diff --git a/web/app/views/settings/requests/_settle.html.erb b/web/app/views/settings/requests/_settle.html.erb new file mode 100644 index 00000000..c67e9c69 --- /dev/null +++ b/web/app/views/settings/requests/_settle.html.erb @@ -0,0 +1,23 @@ +<%= render layout: "fd/modal", locals: { id: "settle-#{asked.id}", + title: asked.app.name, sub: asked.app.slug } do %> + <%= form_with url: settle_settings_request_path(asked), method: :post, + html: { autocomplete: "off" } do %> + + + <% end %> +<% end %> diff --git a/web/app/views/settings/requests/index.html.erb b/web/app/views/settings/requests/index.html.erb new file mode 100644 index 00000000..30047b0e --- /dev/null +++ b/web/app/views/settings/requests/index.html.erb @@ -0,0 +1,58 @@ +<% content_for :page_title, "Access requests" %> + +
+
+

Requests

+
+ <%= @waiting %> waiting +
+
+ + <% if @rows.any? %> +
+ + + + + + + + + + + + + <% @rows.each do |asked| %> + + + + + + + + + <% end %> + +
appownerstatebywhen
+ + <%= face(asked.app.owner_user_id) %> + <%= asked.app.name %> + + <%= names[asked.app.owner_user_id] %><%= access_state_chip(asked.state) %><%= asked.decided_by ? names[asked.decided_by] : "" %> + <%= time_ago_in_words(asked.decided_at || asked.created_at) %> + + <% if asked.pending? %> + + <% end %> +
+
+ <% else %> +
+

No requests

+
+ <% end %> +
+ +<% @rows.select(&:pending?).each do |asked| %> + <%= render "settings/requests/settle", asked: asked %> +<% end %> diff --git a/web/config/routes.rb b/web/config/routes.rb index 34c4d214..2f3c2cd9 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -11,6 +11,36 @@ 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#index", as: :docs + get "docs/:slug", to: "docs#show", as: :doc + + namespace :settings do + resource :appearance, only: [:show], controller: "appearances" + resource :keys, only: [:show], controller: "keys" + resource :permissions, only: [:show], controller: "permissions" + resource :consent, only: [:update], controller: "consents" + resources :apps, only: [:create, :destroy] + resources :tokens, only: [:create, :destroy] do + member { post :rotate } + end + resources :requests, only: [:index, :create] do + member do + post :settle + delete :withdraw + end + end + end + + get "you/api", to: redirect("/settings/keys") + get "cdn/destroy/:key", to: "fd/transcripts#show", as: :destroy_transcript, constraints: { key: %r{[^/]+} }, format: false @@ -108,6 +138,10 @@ resource :roles, only: [:show], controller: "roles" resource :flags, only: [:show], controller: "flags" resource :settings, only: [:show], controller: "settings" + resource :api, only: [:show], controller: "api_settings" + 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 post "settings/firehouse", to: "settings#firehouse", as: :settings_firehouse post "settings/join_mode", to: "settings#join_mode", as: :settings_join_mode post "settings/sweep", to: "settings#sweep", as: :settings_sweep diff --git a/web/test/integration/admin_api_settings_test.rb b/web/test/integration/admin_api_settings_test.rb new file mode 100644 index 00000000..9b46e3e8 --- /dev/null +++ b/web/test/integration/admin_api_settings_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +class AdminApiSettingsTest < ActionDispatch::IntegrationTest + setup do + @boss = hold_role!("UBOSS9", "community_manager") + hold_role!("UFIRE9", "firefighter") + @client = make_app!("UOWNER9") + @token, = Api::Token.mint!(@client, "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 admin_api_path + + assert_response :success + assert_select ".data-table td", text: "Toolbox" + assert_select ".data-table .tag", text: @token.shown + assert_select ".setlist .setname", text: "Requests a minute, per key" + end + + test "a manager moves a dial, and it is written down" do + assert_difference -> { Api::Event.count }, 1 do + patch admin_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 admin_api_setting_path, params: { key: "wingspan", value: 5 } + assert_match(/not a setting/, flash[:alert]) + + patch admin_api_setting_path, params: { key: "rate_per_minute", value: 0 } + assert_match(/above nought/, flash[:alert]) + + patch admin_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 admin_api_token_rate_path(@token), params: { value: 600 } + assert_equal 600, @token.reload.rate + + patch admin_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 admin_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 admin_api_token_path(@token) + + assert_no_difference -> { Api::Event.count } do + delete admin_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(Account.find("UFIRE9")) + + patch admin_api_setting_path, params: { key: "rate_per_minute", value: 999 } + delete admin_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 cannot reach the page at all" do + sign_in_as(Account.find("UFIRE9")) + get admin_api_path + + assert_redirected_to root_path + end +end diff --git a/web/test/integration/api_auth_test.rb b/web/test/integration/api_auth_test.rb new file mode 100644 index 00000000..e8516418 --- /dev/null +++ b/web/test/integration/api_auth_test.rb @@ -0,0 +1,98 @@ +require "test_helper" + +class ApiAuthTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @client = make_app!("UOWNER1") + @token, @secret = Api::Token.mint!(@client, "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 = hold_role!("UBOSS2", "community_manager") + 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..610cc558 --- /dev/null +++ b/web/test/integration/api_channel_managers_test.rb @@ -0,0 +1,159 @@ +require "test_helper" + +class ApiChannelManagersTest < ActionDispatch::IntegrationTest + MANAGER = "U0BGRUHPTTR".freeze + BYSTANDER = "U04KX9TQ2AA".freeze + + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @client = make_app!("UOWNER1") + @token, @secret = Api::Token.mint!(@client, "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, @client.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{/settings/permissions\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..b218432d --- /dev/null +++ b/web/test/integration/api_docs_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class ApiDocsTest < ActionDispatch::IntegrationTest + setup do + @member = Account.create!(user_id: "UMEMBER3") + @topic = Docs::CHANNEL_MANAGERS + end + + test "the docs index sends you to the first topic" do + sign_in_as(@member) + get docs_path + + assert_redirected_to doc_path(@topic.slug) + end + + test "any signed in member can read a topic, role or no role" do + sign_in_as(@member) + get doc_path(@topic.slug) + + assert_response :success + assert_select ".doc-sec", @topic.sections.size + end + + test "a topic nobody wrote falls back rather than raising" do + sign_in_as(@member) + get doc_path("wingspan") + + assert_redirected_to 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 doc_path(@topic.slug) + + assert_select ".doc-spec dd", text: "45/min" + 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 doc_path(@topic.slug) + + listed = css_select("#errors .doc-def > dt").map { |dt| dt.text.split.first } + assert_equal Api::V1::BaseController::CALLER_ERRORS.map { |_status, key, _said| key }.sort, + listed.sort + end + + test "the sidebar and the page cannot drift apart" do + sign_in_as(@member) + get doc_path(@topic.slug) + + listed = css_select("#section-nav .cnav-sub a").map { |link| link["href"].delete_prefix("#") } + rendered = css_select(".doc-sec").map { |sec| sec["id"] } + + assert_equal @topic.sections.map(&:id), listed, "the sidebar lists every section, in order" + assert_equal @topic.sections.map(&:id), rendered, "and every one of them is on the page" + end + + test "the sidebar lists every topic, and marks the one being read" do + sign_in_as(@member) + get doc_path(@topic.slug) + + assert_select "#section-nav .cnav-group a[href=?]", doc_path(@topic.slug) + assert_select "#section-nav a[aria-current=page]", 1 + 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 "every topic has a partial to render, so adding one cannot half land" do + Docs.topics.each do |topic| + assert File.exist?(Rails.root.join("app/views/docs/_#{topic.slug.tr('-', '_')}.html.erb")), + "#{topic.slug} has no partial" + end + end + + test "the docs never name a key somebody actually holds" do + sign_in_as(@member) + app = make_app!(@member.user_id) + _token, secret = Api::Token.mint!(app, "Toolbox") + get doc_path(@topic.slug) + + 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..c157aebf --- /dev/null +++ b/web/test/integration/api_rate_limit_test.rb @@ -0,0 +1,112 @@ +require "test_helper" + +class ApiRateLimitTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @client = make_app!("UOWNER1") + @token, @secret = Api::Token.mint!(@client, "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 + arcade = make_app!("UOWNER2", name: "Arcade") + other, spare = Api::Token.mint!(arcade, "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/member_access_test.rb b/web/test/integration/member_access_test.rb new file mode 100644 index 00000000..c2878b7b --- /dev/null +++ b/web/test/integration/member_access_test.rb @@ -0,0 +1,222 @@ +require "test_helper" + +class MemberAccessTest < ActionDispatch::IntegrationTest + SCOPE = "channel_manager".freeze + WHY = "a slack bot that routes design questions".freeze + + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @member = Account.create!(user_id: "UASK1") + @reviewer = hold_role!("UREV1", "community_manager") + sign_in_as(@member) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def register(name: "Triage Bot", blurb: "routes design questions", reason: WHY, scope: SCOPE) + post settings_apps_path, + params: { name: name, blurb: blurb, scope: scope, reason: reason } + end + + test "registering an app records it and asks in one move" do + assert_difference -> { Api::App.count }, 1 do + assert_difference -> { Api::AccessRequest.count }, 1 do + register + end + end + + app = Api::App.sole + assert_equal [@member.user_id, "Triage Bot", "triage-bot"], + [app.owner_user_id, app.name, app.slug] + assert_equal "pending", Api::AccessRequest.sole.state + assert_equal "access_requested", Api::Event.last.verb + end + + test "two apps of the same name get their own slugs" do + register + register + + assert_equal %w[triage-bot triage-bot-2], Api::App.order(:id).pluck(:slug) + end + + test "an app with nothing said about it is refused" do + assert_no_difference -> { Api::App.count } do + register(blurb: "bot") + end + + assert_match(/say what the app does/, flash[:alert]) + end + + test "a reason that says nothing is refused, and leaves no app behind" do + assert_no_difference -> { Api::App.count } do + register(reason: "pls") + end + + assert_match(/what the app needs/, flash[:alert]) + end + + test "a member cannot mint for an app with no approved scope" do + register + app = Api::App.sole + + assert_no_difference -> { Api::Token.count } do + post settings_tokens_path, params: { app_id: app.id, name: "Toolbox" } + end + + assert_match(/no approved scope/, flash[:alert]) + end + + test "a member cannot mint for somebody else's app" do + theirs = make_app!("UOTHER9", name: "Theirs") + + assert_no_difference -> { Api::Token.count } do + post settings_tokens_path, params: { app_id: theirs.id, name: "Toolbox" } + end + + assert_match(/not yours/, flash[:alert]) + end + + test "a member cannot reach the queue, and a reviewer can" do + get settings_requests_path + assert_redirected_to settings_keys_path + + sign_in_as(@reviewer) + get settings_requests_path + assert_response :success + end + + test "approving grants the app the scope and unlocks minting" do + register + asked = Api::AccessRequest.sole + + sign_in_as(@reviewer) + post settle_settings_request_path(asked), params: { verdict: "Approve" } + + assert_equal "approved", asked.reload.state + assert Api::Approval.held?(asked.app_id) + + sign_in_as(@member) + assert_difference -> { Api::Token.count }, 1 do + post settings_tokens_path, params: { app_id: asked.app_id, name: "Toolbox" } + end + end + + test "declining settles the request and grants nothing" do + register + asked = Api::AccessRequest.sole + + sign_in_as(@reviewer) + post settle_settings_request_path(asked), params: { verdict: "Decline", note: "no" } + + assert_equal ["declined", "no"], [asked.reload.state, asked.note] + assert_not Api::Approval.held?(asked.app_id) + end + + test "a member withdraws their own request and nobody else's" do + register + asked = Api::AccessRequest.sole + + sign_in_as(Account.create!(user_id: "UASK2")) + delete withdraw_settings_request_path(asked) + assert_equal "pending", asked.reload.state + + sign_in_as(@member) + delete withdraw_settings_request_path(asked) + assert_equal "withdrawn", asked.reload.state + end + + test "opting in to one app says nothing about another" do + mine = make_app!(@member.user_id, name: "Mine") + theirs = make_app!("UOTHER9", name: "Theirs") + _token, key = Api::Token.mint!(theirs, "Toolbox") + Api::Consent.set!("USUBJECT1", mine.id, SCOPE, true, via: "dashboard") + + get "/api/v1/channels/C0DESIGN99/managers/USUBJECT1", + headers: { "Authorization" => "Bearer #{key}" } + + assert_response :success + assert_equal "withheld", response.parsed_body["consent"], + "consent for one app must not answer for another" + end + + test "opting in to the asking app answers it" do + theirs = make_app!("UOTHER9", name: "Theirs") + _token, key = Api::Token.mint!(theirs, "Toolbox") + Api::Consent.set!("USUBJECT1", theirs.id, SCOPE, true, via: "dashboard") + + get "/api/v1/channels/C0DESIGN99/managers/USUBJECT1", + headers: { "Authorization" => "Bearer #{key}" } + + assert_response :success + assert_equal "granted", response.parsed_body["consent"] + end + + test "a live key stops answering the moment the app's approval is taken back" do + app = make_app!(@member.user_id) + Api::Consent.set!("USUBJECT1", app.id, SCOPE, true, via: "dashboard") + _token, key = Api::Token.mint!(app, "Toolbox") + + get "/api/v1/channels/C0DESIGN99/managers/USUBJECT1", + headers: { "Authorization" => "Bearer #{key}" } + assert_response :success + + Api::Approval.revoke!(app.id, by: @reviewer.user_id) + + get "/api/v1/channels/C0DESIGN99/managers/USUBJECT1", + headers: { "Authorization" => "Bearer #{key}" } + assert_response :forbidden + assert_equal "not_approved", response.parsed_body["error"] + end + + test "retiring an app revokes its keys and gives up its scopes" do + app = make_app!(@member.user_id) + token, = Api::Token.mint!(app, "Toolbox") + + delete settings_app_path(app) + + assert_predicate app.reload, :retired? + assert_predicate token.reload, :revoked? + assert_not Api::Approval.held?(app.id) + end + + test "retiring an app takes its waiting request out of the queue" do + register + asked = Api::AccessRequest.sole + + delete settings_app_path(asked.app) + + assert_equal "withdrawn", asked.reload.state + assert_empty Api::AccessRequest.queue + + sign_in_as(@reviewer) + get settings_requests_path + assert_select ".data-table tbody .text-btn", { text: "Review", count: 0 }, + "a retired app must not still be waiting on a reviewer" + end + + test "a request for an app somebody retired is never left reviewable" do + register + asked = Api::AccessRequest.sole + asked.app.update!(retired_at: Time.current) + + assert_empty Api::AccessRequest.queue + end + + test "rotating keeps the row and kills the old secret" do + app = make_app!(@member.user_id) + token, was = Api::Token.mint!(app, "Toolbox") + + assert_no_difference -> { Api::Token.count } do + post rotate_settings_token_path(token) + end + + now = css_select(".secret code").sole.text + assert_not_equal was, now + assert_equal Api::Token.digest_of(now), token.reload.digest + assert_equal "Toolbox", token.name + assert_not_nil token.rotated_at + 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..d601174a --- /dev/null +++ b/web/test/integration/member_area_test.rb @@ -0,0 +1,109 @@ +require "test_helper" + +class MemberAreaTest < ActionDispatch::IntegrationTest + setup do + @member = Account.create!(user_id: "UMEMBER1") + @staff = hold_role!("UBOSS1", "community_manager") + end + + def gated_paths + [fd_root_path, fd_cases_path, fd_members_path, fd_audit_path, fd_search_path, + engine_path, admin_root_path] + end + + test "a member with no role signs in and reaches the front door" do + sign_in_as(@member) + + assert_redirected_to root_path + follow_redirect! + assert_response :success + end + + test "a member with no role holds no capability at all" do + Authz.keys.reject { |key| Authz.every_account?(key) }.each do |key| + assert_not @member.may?(key), "a member with no role must not hold #{key}" + end + end + + test "the conduct and ops pages are still shut to a member with no role" do + sign_in_as(@member) + + gated_paths.each do |path| + get path + assert_redirected_to root_path, "#{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 root_path + 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 :forbidden + end + + test "signed out, the member page sends you to sign in" do + get settings_keys_path + + assert_redirected_to login_path + end + + test "signing out shuts the member page again" do + sign_in_as(@member) + get settings_keys_path + assert_response :success + + delete logout_path + get settings_keys_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: {} } + ) + get "/auth/hackclub/callback" + + assert_redirected_to auth_failure_path(message: "no_slack_id") + + get settings_keys_path + assert_redirected_to login_path + end + + test "the rail offers a member with no role no way into fire engine" do + sign_in_as(@member) + get settings_keys_path + + assert_response :success + assert_select ".rail-btn[href=?]", fd_root_path, count: 0 + assert_select ".rail-btn[href=?]", admin_root_path, count: 0 + end + + test "a community manager keeps fire engine and admin in the rail" do + sign_in_as(@staff) + get settings_keys_path + + assert_response :success + assert_select ".rail-btn[href=?]", fd_root_path + assert_select ".rail-btn[href=?]", admin_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 root_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..f670db07 --- /dev/null +++ b/web/test/integration/member_consent_test.rb @@ -0,0 +1,136 @@ +require "test_helper" + +class MemberConsentTest < ActionDispatch::IntegrationTest + CAP = "channel_manager".freeze + + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @member = Account.create!(user_id: "UMEMBER2") + @client = make_app!("UOWNER2", name: "Triage Bot") + sign_in_as(@member) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def flip(on, app: @client, scope: CAP, **extra) + patch settings_consent_path(app_id: app.id, scope: scope, on: on, **extra) + end + + def state(app: @client) + Api::Consent.find_by(user_id: @member.user_id, app_id: app.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 [@member.user_id, @client.id, "dashboard"], + Api::ConsentLog.last.then { |log| [log.user_id, log.app_id, log.via] } + 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") + row = Api::Consent.find_by(user_id: @member.user_id, app_id: @client.id, capability: CAP) + first = row.first_granted_at + flip("0") + + assert_equal first, row.reload.first_granted_at + end + + test "one app at a time, so opting in never leaks to another" do + other = make_app!("UOWNER3", name: "Standup Helper") + flip("1") + + assert_equal "granted", state + assert_nil state(app: other) + 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 scope the app was never approved for is refused" do + quiet = Api::App.register!("UOWNER4", name: "Quiet", blurb: "asks for nothing yet") + + assert_no_difference -> { Api::ConsentLog.count } do + flip("1", app: quiet) + end + + assert_match(/no such app/, flash[:alert]) + end + + test "a scope nobody declared is refused and writes nothing" do + assert_no_difference -> { Api::ConsentLog.count } do + flip("1", scope: "read_my_email") + end + + assert_match(/not a scope/, 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 lists the app by name and offers opting in, then out" do + get settings_permissions_path + + assert_select ".fold-title", text: "Triage Bot" + assert_select ".data-table .btn", text: "Opt in" + + flip("1") + get settings_permissions_path + + assert_select ".data-table .btn", text: "Opt out" + end + + test "an app nobody approved is not offered at all" do + Api::App.register!("UOWNER5", name: "Unapproved", blurb: "has asked for nothing") + get settings_permissions_path + + assert_select ".fold-title", text: "Unapproved", count: 0 + 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 settings_permissions_path + + assert_select ".data-table .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..41932728 --- /dev/null +++ b/web/test/integration/member_tokens_test.rb @@ -0,0 +1,124 @@ +require "test_helper" + +class MemberTokensTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @member = Account.create!(user_id: "UMEMBER4") + @client = make_app!(@member.user_id) + sign_in_as(@member) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def mint(name: "Toolbox", lasting: "90") + post settings_tokens_path, params: { app_id: @client.id, name: name, lasting: lasting } + end + + test "the form does not go through turbo, so the answer is not thrown away" do + get settings_keys_path + + assert_select "form[action=?][data-turbo=false]", settings_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: "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 settings_keys_path + 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, Toolbox, 30 days", said.detail + end + + test "past the cap it is refused, and a revoked key frees a slot" do + cap = Api::Setting.value("tokens_per_owner") + cap.times { |i| mint(name: "key #{i}") } + assert_equal cap, Api::Token.count + + mint(name: "one too many") + assert_equal cap, Api::Token.count + assert_match(/already holds #{cap}/, flash[:alert]) + + Api::Token.first.revoke!(by: @member.user_id) + mint(name: "room now") + + assert_equal cap + 1, Api::Token.count + end + + test "an expired key does not hold a slot" do + cap = Api::Setting.value("tokens_per_owner") + cap.times { |i| mint(name: "key #{i}") } + Api::Token.first.update!(expires_at: 1.day.ago) + + mint(name: "room now") + + assert_equal cap + 1, 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 8e4992e9..1ad157f7 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -8,6 +8,15 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest INSIDE = %i[root_path fd_root_path fd_members_path admin_people_path].freeze + MEMBER = %w[settings/keys settings/tokens settings/apps settings/consents + settings/permissions settings/appearances docs accounts workspace_logo + channels home fd/slack_accounts].freeze + + ROLE_GATES = %i[require_fd require_admin require_operating require_reading + require_reviewer].freeze + + BEARER = %w[api/v1/tokens api/v1/channel_managers].freeze + setup do Rails.application.eager_load! @me = Account.create!(user_id: "UNOROLE") @@ -17,13 +26,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_account } + filters_of(name).include?(:require_account) + end + + def role_guarded?(name) + filters_of(name).intersect?(ROLE_GATES) + 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) || token_guarded?(name) + end end test "the only routes open to the world are signing in and the health check" do @@ -31,12 +53,41 @@ 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 role_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 guarded?(name), "#{name} lets anybody through" + assert_not role_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| role_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 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 TURBO.each do |path| get path 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/test_helper.rb b/web/test/test_helper.rb index 80568607..bb5d279c 100644 --- a/web/test/test_helper.rb +++ b/web/test/test_helper.rb @@ -125,6 +125,20 @@ def make_case(subject: "USUB", assign: nil, **attrs) kase end + def make_app!(owner, name: "Toolbox", approved: true, by: "UBOSS") + app = Api::App.register!(owner, name: name, blurb: "answers a question for the team") + Api::Approval.grant!(app.id, by: by) if approved + app + 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(