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 @@ -
| app | +key name | +key | +rate | +expires | +asks 30d | +last 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 %> + | +
No tokens
++ 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. +
+ +403 not_approved. Retiring an app revokes every key it held.
+
+ 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.
+
Authorization: Bearer nemo_live_7Fj2QcW8xR4mLpVdA1sT+
{ "name": "Toolbox", "prefix": "nemo_live_7Fj2",
+ "app": "triage-bot", "rate_per_minute": <%= @rate %> }
+ /api/v1/channels/{channel_id}/managers/{user_id}
+ One channel and one member per request. There is no batch form.
+ +withheld.
+ {
+ "channel_id": "C0P5NE3M0",
+ "user_id": "U0A5PLKMB25",
+ "consent": "granted",
+ "is_manager": true,
+ "synced_at": "2026-08-31T04:12:07Z",
+ "stale": false
+}
+ {
+ "channel_id": "C0P5NE3M0",
+ "user_id": "U0A5PLKMB25",
+ "consent": "withheld",
+ "is_manager": null,
+ "opt_in_url": "<%= settings_permissions_url %>"
+}
+ granted or withheld. Always present.null whenever consent is withheld.true past the cache TTL. Answer still served.+ Members opt in to one app at a time. Consent for somebody else's app says nothing about + yours, and a member who opted in to you last month has not opted in to anyone since. +
+ +Branch on consent, not on the status code. Withheld is a 200.
+ A channel with no managers on record, or one that does not exist, answers
+ is_manager: false. The API never confirms whether a channel exists.
+
synced_at, no
+ stale. Mnemosyne does not message members on your behalf; surface
+ opt_in_url yourself.
+
+ <%= @rate %> requests per fixed 60 second window, per key. Overridable per key
+ by a community manager.
+
RateLimit-Limit: <%= @rate %> +RateLimit-Remaining: <%= [@rate - 3, 0].max %> +RateLimit-Reset: 34 // seconds until the window rolls +Retry-After: 34 // 429 only, matches body.retry_after+
+ The first three are on every answered request. Refusals raised before the key is resolved + carry none, since there is no budget to report. +
+{ "error": "rate_limited", "message": "rate limit spent, see retry_after", "retry_after": 34 }
+ + + Calls using <%= token.shown %> start failing straight away. It cannot be + un-revoked, and a replacement is a new key. +
++ + Shown once. If you need it again, you’ve to rotate it +
+<%= @secret %>
+
+ + + Once it’s deleted, it’d be gone. If you need it again, you’d have + to request a new app +
+No apps
+| 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" } %> + + | +
| 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 %> + + | +
<%= asked.app.blurb %>
+<%= asked.reason %>
++ <%= names[asked.app.owner_user_id] %> · + requested <%= time_ago_in_words(asked.created_at) %> ago +
+ +| app | +owner | +state | +by | +when | ++ |
|---|---|---|---|---|---|
| + + <%= 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 %> + | +
No requests
+