From bb59b3ce8a22156040aa25dbfdde7c74b37c1c1d Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 00:14:29 +0300 Subject: [PATCH 01/16] pipeline: probe slack for the channel manager role id --- deploy/env/serve.env.example | 1 + pipeline/checks/slack_roles.py | 168 +++++++++++++++++++++++++++++++++ pipeline/lib/config.py | 1 + pipeline/slack.manifest.yaml | 1 + proxy/app.py | 6 ++ 5 files changed, 177 insertions(+) create mode 100644 pipeline/checks/slack_roles.py diff --git a/deploy/env/serve.env.example b/deploy/env/serve.env.example index 4f740cfd..2b542538 100644 --- a/deploy/env/serve.env.example +++ b/deploy/env/serve.env.example @@ -28,6 +28,7 @@ FD_ENCRYPTION_DETERMINISTIC_KEY= FD_ENCRYPTION_SALT= INTERNAL_PROXY_URL= SLACKSCAN_URL= +SLACK_CHANNEL_MANAGER_ROLE_ID= PROXY_TOKEN_WEB= PROXY_ALLOW_PLAINTEXT= RAILS_MAX_THREADS=5 diff --git a/pipeline/checks/slack_roles.py b/pipeline/checks/slack_roles.py new file mode 100644 index 00000000..d4b68c76 --- /dev/null +++ b/pipeline/checks/slack_roles.py @@ -0,0 +1,168 @@ +import argparse +import os +import sys +from collections import defaultdict + +from dotenv import load_dotenv +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from lib.paths import ENV_FILE + +PAGE_SIZE = 200 +SAMPLES = 3 +KINDS = {"C": "channel", "G": "private group", "T": "workspace", "E": "enterprise"} + +BLOCKING = { + "missing_scope", + "not_allowed_token_type", + "invalid_auth", + "account_inactive", + "token_revoked", + "not_authed", +} + + +def die(message): + sys.exit(f"slack_roles: {message}") + + +def api(token): + if not token: + die("no token. pass one as an argument or set SLACK_TOKEN to an xoxp user token") + return WebClient(token=token) + + +def whoami(client): + try: + said = client.auth_test().data + except SlackApiError as exc: + die(f"auth.test failed: {exc.response.get('error', 'unknown_error')}") + return said + + +def call(client, **params): + try: + return client.admin_roles_listAssignments(limit=PAGE_SIZE, **params) + except SlackApiError as exc: + error = exc.response.get("error", "unknown_error") + if error == "missing_scope": + needed = exc.response.get("needed", "admin.roles:read") + die(f"the token is missing {needed}. add it and reinstall the app") + if error in BLOCKING: + die(f"admin.roles.listAssignments refused this token: {error}") + die(f"admin.roles.listAssignments failed: {error}") + + +def walk(client, pages=None, **params): + cursor = "" + seen = 0 + while True: + page = call(client, cursor=cursor or None, **params) + for one in page.get("role_assignments", []): + yield one + seen += 1 + cursor = page.get("response_metadata", {}).get("next_cursor") or "" + if not cursor or (pages is not None and seen >= pages): + return + + +def kind_of(entity_id): + return KINDS.get(entity_id[:1], entity_id[:1] or "?") + + +def gather(client, pages): + found = defaultdict(lambda: {"count": 0, "entities": set(), "kinds": set(), "users": set()}) + for one in walk(client, pages=pages): + entity = one.get("entity_id", "") + role = found[one.get("role_id", "?")] + role["count"] += 1 + role["entities"].add(entity) + role["kinds"].add(kind_of(entity)) + role["users"].add(one.get("user_id", "")) + return found + + +def report(found, capped): + print() + print(f" {'role_id':<10}{'assigned':>10}{'entities':>10} {'kind':<14}samples") + for role_id, one in sorted(found.items(), key=lambda pair: -pair[1]["count"]): + kinds = ", ".join(sorted(one["kinds"])) + samples = ", ".join(sorted(one["entities"])[:SAMPLES]) + print(f" {role_id:<10}{one['count']:>10}{len(one['entities']):>10} {kinds:<14}{samples}") + if capped: + print(f"\n counts cover the first {capped} pages only. pass --all to sweep everything.") + + +def channel_role(found): + named = [role for role, one in found.items() if "channel" in one["kinds"]] + if len(named) == 1: + return named[0] + if not named: + print("\n no role has channel entities. nothing here maps to channel manager.") + return None + print(f"\n more than one role has channel entities: {', '.join(sorted(named))}") + print(" pass --role to check one of them against a channel you know.") + return None + + +def check_filter(client, role_id, channel_id): + print(f"\n filter check: role_ids={role_id} entity_ids={channel_id}") + rows = list(walk(client, role_ids=[role_id], entity_ids=[channel_id])) + if not rows: + print(" nothing came back. the filter may not combine, or nobody manages that channel.") + return + stray = {row.get("entity_id") for row in rows} - {channel_id} + users = sorted({row.get("user_id", "") for row in rows}) + print(f" {len(rows)} assignments, {len(users)} people") + print(f" managers: {', '.join(users[:8])}{' ...' if len(users) > 8 else ''}") + if stray: + print(f" WARNING entity_ids did not narrow it. also returned: {', '.join(sorted(stray)[:5])}") + else: + print(" every row is on that channel, so one call answers one channel") + + +def main(): + parser = argparse.ArgumentParser( + prog="slack_roles", + description="find which Slack role id means channel manager, and prove that " + "role_ids and entity_ids together answer one channel in one call", + ) + parser.add_argument("token", nargs="?", help="an xoxp user token, else SLACK_TOKEN is used") + parser.add_argument("--all", action="store_true", help="sweep every page instead of the first few") + parser.add_argument("--pages", type=int, default=10, help="pages to sample, 200 per page") + parser.add_argument("--role", help="skip discovery and check this role id") + parser.add_argument("--channel", help="channel id for the filter check") + args = parser.parse_args() + + load_dotenv(ENV_FILE) + client = api(args.token or os.environ.get("SLACK_TOKEN", "").strip()) + who = whoami(client) + print(f"admin.roles.listAssignments as {who.get('user')} ({who.get('user_id')}) " + f"on {who.get('team')} / {who.get('team_id')}") + + role_id = args.role + channel_id = args.channel + + if role_id is None: + pages = None if args.all else args.pages + found = gather(client, pages) + if not found: + die("no role assignments came back at all") + report(found, None if args.all else args.pages) + role_id = channel_role(found) + if role_id is None: + return + if channel_id is None: + channel_id = sorted(found[role_id]["entities"])[0] + print(f"\n channel manager looks like {role_id}") + print(f" pin it: SLACK_CHANNEL_MANAGER_ROLE_ID={role_id}") + + if channel_id is None: + die("pass --channel with --role so the filter can be checked") + + check_filter(client, role_id, channel_id) + + +if __name__ == "__main__": + main() diff --git a/pipeline/lib/config.py b/pipeline/lib/config.py index 193f74f4..9855d58a 100644 --- a/pipeline/lib/config.py +++ b/pipeline/lib/config.py @@ -33,6 +33,7 @@ "FD_ENCRYPTION_SALT", "INTERNAL_PROXY_URL", "SLACKSCAN_URL", + "SLACK_CHANNEL_MANAGER_ROLE_ID", "PROXY_TOKEN_WEB", "PROXY_ALLOW_PLAINTEXT", "RAILS_MAX_THREADS", diff --git a/pipeline/slack.manifest.yaml b/pipeline/slack.manifest.yaml index 23596050..614d5172 100644 --- a/pipeline/slack.manifest.yaml +++ b/pipeline/slack.manifest.yaml @@ -17,6 +17,7 @@ oauth_config: - admin.analytics:read - admin.users:read - admin.teams:read + - admin.roles:read settings: event_subscriptions: diff --git a/proxy/app.py b/proxy/app.py index 555b3508..feaf0aba 100644 --- a/proxy/app.py +++ b/proxy/app.py @@ -29,6 +29,7 @@ "admin": frozenset( { "admin.users.list", + "admin.roles.listAssignments", "search.messages", "conversations.replies", } @@ -44,6 +45,11 @@ "admin.analytics.getAvailableDateRange", } ), + "admin": frozenset( + { + "admin.roles.listAssignments", + } + ), } CREDENTIALS = ("internal", "admin") From 8207d9d14f813f9d85a8ff79063483612bdaa365 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 00:30:36 +0300 Subject: [PATCH 02/16] db: add the api schema and channel manager assignments --- db/init.sql | 20 +++++ .../0055_api_and_channel_managers.sql | 82 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 db/migrations/0055_api_and_channel_managers.sql diff --git a/db/init.sql b/db/init.sql index 8b26018f..532aa055 100644 --- a/db/init.sql +++ b/db/init.sql @@ -2,6 +2,7 @@ CREATE SCHEMA IF NOT EXISTS raw; CREATE SCHEMA IF NOT EXISTS analytics; CREATE SCHEMA IF NOT EXISTS app; CREATE SCHEMA IF NOT EXISTS fd; +CREATE SCHEMA IF NOT EXISTS api; DO $$ DECLARE @@ -71,6 +72,25 @@ BEGIN EXECUTE 'GRANT USAGE ON ALL SEQUENCES IN SCHEMA fd TO rails_app'; EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA fd GRANT USAGE ON SEQUENCES TO rails_app'; + EXECUTE 'GRANT USAGE ON SCHEMA api TO rails_app'; + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA api TO rails_app'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api ' + 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rails_app'; + EXECUTE 'GRANT USAGE ON ALL SEQUENCES IN SCHEMA api TO rails_app'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api GRANT USAGE ON SEQUENCES TO rails_app'; + + EXECUTE 'GRANT USAGE ON SCHEMA api TO pipeline_writer'; + EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA api ' + 'TO pipeline_writer'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api ' + 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pipeline_writer'; + EXECUTE 'GRANT USAGE ON ALL SEQUENCES IN SCHEMA api TO pipeline_writer'; + EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA api ' + 'GRANT USAGE ON SEQUENCES TO pipeline_writer'; + + EXECUTE 'REVOKE ALL ON ALL TABLES IN SCHEMA api FROM dbt_owner'; + EXECUTE 'REVOKE ALL ON SCHEMA api FROM dbt_owner'; + IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'app' AND tablename = 'sync_request') THEN EXECUTE 'GRANT SELECT, UPDATE ON app.sync_request TO pipeline_writer'; END IF; diff --git a/db/migrations/0055_api_and_channel_managers.sql b/db/migrations/0055_api_and_channel_managers.sql new file mode 100644 index 00000000..ee7f2c10 --- /dev/null +++ b/db/migrations/0055_api_and_channel_managers.sql @@ -0,0 +1,82 @@ +CREATE SCHEMA IF NOT EXISTS api; + +CREATE TABLE api.channel_manager ( + channel_id text NOT NULL, + user_id text NOT NULL, + assigned_at timestamptz, + PRIMARY KEY (channel_id, user_id) +); + +CREATE INDEX channel_manager_user_idx ON api.channel_manager (user_id); + +CREATE TABLE api.channel_sweep ( + channel_id text PRIMARY KEY, + synced_at timestamptz NOT NULL DEFAULT now(), + managers integer NOT NULL DEFAULT 0 +); + +CREATE TABLE api.consent ( + user_id text NOT NULL, + capability text NOT NULL, + state text NOT NULL, + changed_at timestamptz NOT NULL DEFAULT now(), + changed_via text NOT NULL, + first_granted_at timestamptz, + PRIMARY KEY (user_id, capability), + CONSTRAINT consent_state CHECK (state IN ('granted', 'withheld')) +); + +CREATE INDEX consent_granted_idx ON api.consent (capability) + WHERE state = 'granted'; + +CREATE TABLE api.consent_log ( + id bigserial PRIMARY KEY, + user_id text NOT NULL, + capability text NOT NULL, + state text NOT NULL, + via text NOT NULL, + at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX consent_log_at_idx ON api.consent_log (at DESC); + +CREATE TABLE api.token ( + id bigserial PRIMARY KEY, + owner_user_id text NOT NULL, + name text NOT NULL, + prefix text NOT NULL, + digest text NOT NULL UNIQUE, + rate_limit integer, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + revoked_at timestamptz, + revoked_by text, + CONSTRAINT token_rate_limit_sane CHECK (rate_limit IS NULL OR rate_limit > 0) +); + +CREATE INDEX token_owner_idx ON api.token (owner_user_id) + WHERE revoked_at IS NULL; + +CREATE TABLE api.setting ( + key text PRIMARY KEY, + value integer NOT NULL, + changed_by text, + changed_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO api.setting (key, value) VALUES + ('rate_per_minute', 100), + ('batch_max', 100), + ('tokens_per_owner', 3); + +CREATE TABLE api.request_log ( + id bigserial PRIMARY KEY, + token_id bigint NOT NULL REFERENCES api.token (id), + channel_id text, + subject_user_id text, + outcome text NOT NULL, + at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX request_log_subject_idx ON api.request_log (subject_user_id, at DESC); +CREATE INDEX request_log_token_idx ON api.request_log (token_id, at DESC); From 7ba921c7be03ce80df598501f37f8a8d7b77e71b Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 00:35:39 +0300 Subject: [PATCH 03/16] web(fd): split the audit log by source --- web/app/controllers/fd/audits_controller.rb | 8 ++-- web/app/models/fd/deeds.rb | 36 +++++++++++++++- web/app/views/fd/audits/show.html.erb | 16 +++++-- web/test/models/fd/deeds_test.rb | 48 +++++++++++++++++++++ 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/web/app/controllers/fd/audits_controller.rb b/web/app/controllers/fd/audits_controller.rb index 5d6d19bb..b7e81de7 100644 --- a/web/app/controllers/fd/audits_controller.rb +++ b/web/app/controllers/fd/audits_controller.rb @@ -6,12 +6,14 @@ class AuditsController < BaseController PER_PAGE = 100 def show - counting = Deeds.new(nil, since: WINDOW.ago) - @total = counting.total + @counts = Deeds.new(nil, since: WINDOW.ago).totals + @view = Deeds.view_for(params[:view]) + @seen = @view unless @view == "all" + @total = @counts.fetch(@view, 0) @pages = [(@total / PER_PAGE.to_f).ceil, 1].max @page = [params[:page].to_i, 1].max.clamp(1, @pages) - @deeds = Deeds.new(nil, since: WINDOW.ago, limit: PER_PAGE, + @deeds = Deeds.new(nil, since: WINDOW.ago, view: @view, limit: PER_PAGE, offset: (@page - 1) * PER_PAGE) @rows = @deeds.rows @names = Names.for(@deeds.member_ids) diff --git a/web/app/models/fd/deeds.rb b/web/app/models/fd/deeds.rb index 7ecadb7c..ff6b3771 100644 --- a/web/app/models/fd/deeds.rb +++ b/web/app/models/fd/deeds.rb @@ -3,14 +3,27 @@ class Deeds LIMIT = 40 READ = "identity.read".freeze + VIEWS = { + "all" => { tab: "Everything", head: "Everything anyone did" }, + "audit" => { tab: "Firefighters", head: "Everything firefighters did" }, + "read" => { tab: "Identity reads", head: "Every identity read" } + }.freeze + Row = Struct.new(:at, :event, :kind, :id, :about, :who, :said, :actor, keyword_init: true) ON_CASE = %w[case participant assignee thread citation].freeze - def initialize(user_id, since:, only: nil, limit: LIMIT, offset: 0) + def self.view_for(asked) + VIEWS.key?(asked.to_s) ? asked.to_s : "all" + end + + attr_reader :view + + def initialize(user_id, since:, only: nil, view: nil, limit: LIMIT, offset: 0) @user_id = user_id @since = since @only = only + @view = self.class.view_for(view) @limit = limit @offset = offset end @@ -23,6 +36,10 @@ def total @total ||= picked("count(*) AS found").first["found"].to_i end + def totals + @totals ||= tallied + end + def member_ids rows.flat_map { |row| [row.kind == "member" ? row.id : row.who, row.actor] }.compact.uniq end @@ -47,7 +64,23 @@ def nothing_asked? audit_side.strip.empty? && reads_side.strip.empty? end + def tallied + counted = VIEWS.keys.excluding("all").index_with(0) + return counted if nothing_asked? + + sql = <<~SQL + WITH picked AS (#{audit_side}#{reads_side}) + SELECT kind, count(*) AS found FROM picked GROUP BY kind + SQL + found = AuditEntry.connection.select_all( + AuditEntry.sanitize_sql([sql, { since: @since, who: @user_id }]) + ) + found.each { |row| counted[row["kind"]] = row["found"].to_i } + counted.merge("all" => counted.values.sum) + end + def audit_side + return "" if @view == "read" return "" if @only && @only != READ && Permission.events(@only).empty? return "" if @only == READ @@ -60,6 +93,7 @@ def audit_side end def reads_side + return "" if @view == "audit" return "" if @only && @only != READ mine = @user_id ? "AND l.actor_id = :who" : "" diff --git a/web/app/views/fd/audits/show.html.erb b/web/app/views/fd/audits/show.html.erb index 707f3fe0..972dc8eb 100644 --- a/web/app/views/fd/audits/show.html.erb +++ b/web/app/views/fd/audits/show.html.erb @@ -1,8 +1,18 @@ <% content_for :page_title, "Audit log" %>
+
+ <% Fd::Deeds::VIEWS.each do |key, labels| %> + <%= link_to fd_audit_path(key == "all" ? {} : { view: key }), class: "view", + aria: { current: ("true" if key == @view) } do %> + <%= labels[:tab] %> + <%= number_with_delimiter(@counts.fetch(key, 0)) %> + <% end %> + <% end %> +
+
- Everything firefighters did + <%= Fd::Deeds::VIEWS.fetch(@view)[:head] %> last 30 days · <%= pluralize(@total, "entry") %> @@ -46,13 +56,13 @@ <% if @pages > 1 %>
<% if @page > 1 %> - <%= link_to "Back", fd_audit_path(page: @page - 1), class: "btn" %> + <%= link_to "Back", fd_audit_path(page: @page - 1, view: @seen), class: "btn" %> <% else %> Back <% end %> Page <%= @page %> of <%= @pages %> <% if @page < @pages %> - <%= link_to "Next", fd_audit_path(page: @page + 1), class: "btn" %> + <%= link_to "Next", fd_audit_path(page: @page + 1, view: @seen), class: "btn" %> <% else %> Next <% end %> diff --git a/web/test/models/fd/deeds_test.rb b/web/test/models/fd/deeds_test.rb index aef76bbf..b6f6acaa 100644 --- a/web/test/models/fd/deeds_test.rb +++ b/web/test/models/fd/deeds_test.rb @@ -114,6 +114,54 @@ def deeds(only: nil, **opts) assert_equal %w[case/resolved case/claimed], deeds(limit: 2).map(&:event) end + def both_kinds + audit("case", make_case.id, "opened") + AccessLog.create!(actor_id: WHO, subject_user_id: "USUB", field_class: "identity", + looked_at: 2.days.ago) + end + + test "a view keeps only its own source, and no view keeps nothing" do + both_kinds + + assert_equal %w[case/opened identity/read], deeds.map(&:event).sort + assert_equal ["case/opened"], deeds(view: "audit").map(&:event) + assert_equal ["identity/read"], deeds(view: "read").map(&:event) + end + + test "an unknown view falls back to everything rather than to nothing" do + both_kinds + + assert_equal deeds.size, deeds(view: "nonsense").size + assert_equal "all", Fd::Deeds.view_for("nonsense") + assert_equal "all", Fd::Deeds.view_for(nil) + end + + test "the per-view counts add up to the unfiltered total" do + both_kinds + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + + assert_equal 1, counted["audit"] + assert_equal 1, counted["read"] + assert_equal counted["all"], counted["audit"] + counted["read"] + assert_equal Fd::Deeds.new(WHO, since: 30.days.ago).total, counted["all"] + end + + test "counting a view agrees with paging it" do + both_kinds + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + + Fd::Deeds::VIEWS.each_key do |key| + assert_equal deeds(view: key).size, counted.fetch(key), + "#{key} counts one way and pages another" + end + end + + test "a view still counts every source when there is nothing to count" do + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + + assert_equal({ "audit" => 0, "read" => 0, "all" => 0 }, counted) + end + test "the members it mentions are the ones a name is needed for" do kase = make_case action = Fd::Action.create!(case_id: kase.id, type_key: "warning", target_user_id: "USUB", From 760e802412a5f548fbeea947c22830f24e5bb347 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 00:39:24 +0300 Subject: [PATCH 04/16] db: add the public_api flag --- db/flags.yml | 5 +++++ web/app/models/fd/flag.rb | 2 ++ web/app/views/fd/settings/show.html.erb | 4 +++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/db/flags.yml b/db/flags.yml index b6bf80f6..ad0cd677 100644 --- a/db/flags.yml +++ b/db/flags.yml @@ -11,3 +11,8 @@ flags: label: Decisions covers: The decisions section, and linking a case to one default: true + public_api: + label: Public API + covers: Channel manager lookups, tokens and opt-in + audience: Everyone in Slack + default: false diff --git a/web/app/models/fd/flag.rb b/web/app/models/fd/flag.rb index 73298a8e..5e72b432 100644 --- a/web/app/models/fd/flag.rb +++ b/web/app/models/fd/flag.rb @@ -15,6 +15,8 @@ def self.label(key) = fetch(key).fetch("label") def self.covers(key) = fetch(key).fetch("covers") + def self.audience(key) = fetch(key)["audience"] + def self.default?(key) = fetch(key).fetch("default") == true def self.flipped diff --git a/web/app/views/fd/settings/show.html.erb b/web/app/views/fd/settings/show.html.erb index 74d699d5..5fd08fbb 100644 --- a/web/app/views/fd/settings/show.html.erb +++ b/web/app/views/fd/settings/show.html.erb @@ -107,7 +107,9 @@ <%= Fd::Flag.label(key) %> <%= Fd::Flag.covers(key) %> - <%= pluralize(holders, "person") %> + + <%= Fd::Flag.audience(key) || pluralize(holders, "person") %> + <%= flag_switch(key) %> <% end %> From a1fe117099e38922205cbcf3fa5c40ebfacb555a Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 00:50:09 +0300 Subject: [PATCH 05/16] web: let a member without a role sign in --- .../controllers/dev_sessions_controller.rb | 9 +- web/app/controllers/sessions_controller.rb | 28 ++-- web/app/controllers/you/api_controller.rb | 7 + web/app/controllers/you/base_controller.rb | 25 ++++ web/app/views/layouts/application.html.erb | 18 ++- web/app/views/sessions/failure.html.erb | 5 +- web/app/views/you/api/show.html.erb | 32 +++++ web/config/routes.rb | 4 + web/test/controllers/home_controller_test.rb | 6 +- .../controllers/sessions_controller_test.rb | 15 ++- web/test/integration/member_area_test.rb | 120 ++++++++++++++++++ web/test/integration/who_gets_in_test.rb | 57 +++++++-- 12 files changed, 289 insertions(+), 37 deletions(-) create mode 100644 web/app/controllers/you/api_controller.rb create mode 100644 web/app/controllers/you/base_controller.rb create mode 100644 web/app/views/you/api/show.html.erb create mode 100644 web/test/integration/member_area_test.rb diff --git a/web/app/controllers/dev_sessions_controller.rb b/web/app/controllers/dev_sessions_controller.rb index b066fafa..fc41827b 100644 --- a/web/app/controllers/dev_sessions_controller.rb +++ b/web/app/controllers/dev_sessions_controller.rb @@ -6,10 +6,15 @@ def create staff = Staff.find_or_initialize_by(user_id: params[:user_id]) reset_session + unless You::BaseController::MEMBER_ID.match?(staff.user_id) + return redirect_to login_path, alert: "#{staff.user_id} is not a slack id" + end + + session[:user_id] = staff.user_id + if staff.role.nil? - redirect_to login_path, alert: "#{staff.user_id} is not allowlisted" + redirect_to you_api_path, notice: "signed in as #{staff.user_id}, no role" else - session[:user_id] = staff.user_id redirect_to fd_root_path, notice: "signed in as #{staff.user_id}, #{staff.role}" end end diff --git a/web/app/controllers/sessions_controller.rb b/web/app/controllers/sessions_controller.rb index 34fcb005..f9f263b4 100644 --- a/web/app/controllers/sessions_controller.rb +++ b/web/app/controllers/sessions_controller.rb @@ -3,22 +3,27 @@ class SessionsController < ApplicationController skip_before_action :require_staff def new - redirect_to root_path if current_staff&.role.present? + if current_staff&.role.present? + redirect_to root_path + elsif session[:user_id].present? + redirect_to you_api_path + end end def create auth = request.env["omniauth.auth"] - slack_id = auth&.extra&.raw_info&.[]("slack_id") - staff = slack_id.present? ? Staff.find_or_initialize_by(user_id: slack_id) : nil + slack_id = auth&.extra&.raw_info&.[]("slack_id").to_s + return refuse("no_slack_id") unless You::BaseController::MEMBER_ID.match?(slack_id) + + staff = Staff.find_or_initialize_by(user_id: slack_id) + reset_session + session[:user_id] = staff.user_id - if staff&.role.present? - reset_session - session[:user_id] = staff.user_id + if staff.role.present? flash[:said] = "Everything you do from here is recorded against #{staff.user_id}." redirect_to root_path, notice: "Signed in as a #{staff.role.tr('_', ' ')}" else - reset_session - redirect_to auth_failure_path(message: "not_allowlisted") + redirect_to you_api_path, notice: "Signed in as #{staff.user_id}" end end @@ -31,4 +36,11 @@ def destroy reset_session redirect_to login_path end + + private + + def refuse(message) + reset_session + redirect_to auth_failure_path(message: message) + end end diff --git a/web/app/controllers/you/api_controller.rb b/web/app/controllers/you/api_controller.rb new file mode 100644 index 00000000..abb5f140 --- /dev/null +++ b/web/app/controllers/you/api_controller.rb @@ -0,0 +1,7 @@ +module You + class ApiController < BaseController + def show + @rooms = SlackScan.channels(member_id) + end + end +end diff --git a/web/app/controllers/you/base_controller.rb b/web/app/controllers/you/base_controller.rb new file mode 100644 index 00000000..8b746ce2 --- /dev/null +++ b/web/app/controllers/you/base_controller.rb @@ -0,0 +1,25 @@ +module You + class BaseController < ApplicationController + MEMBER_ID = /\A[UW][A-Z0-9]{2,}\z/ + + skip_before_action :require_staff + before_action :require_a_member + + helper_method :member_id + + private + + def member_id + session[:user_id].to_s + end + + def require_a_member + return if MEMBER_ID.match?(member_id) + + reset_session + return head :unauthorized if request.format.json? + + redirect_to login_path, alert: "sign in to continue" + end + end +end diff --git a/web/app/views/layouts/application.html.erb b/web/app/views/layouts/application.html.erb index 76355349..8b54b649 100644 --- a/web/app/views/layouts/application.html.erb +++ b/web/app/views/layouts/application.html.erb @@ -41,7 +41,8 @@ <%= render "shared/synthetic_banner" %> - <% fire_engine = on?(:fire_engine) %> + <% staffer = current_staff&.role.present? %> + <% fire_engine = staffer && on?(:fire_engine) %>
" data-palette-url-value="<%= fd_search_path(format: :json) %>" data-palette-on-value="<%= content_for(:palette_on) %>" @@ -52,7 +53,7 @@ Mnemosyne
- <% if on?(:analytics) %> + <% if staffer && on?(:analytics) %> <% end %> + + <% if fire_engine %>
<% if current_staff&.may?("access.read") %> diff --git a/web/app/views/sessions/failure.html.erb b/web/app/views/sessions/failure.html.erb index 793ea193..2dd9824b 100644 --- a/web/app/views/sessions/failure.html.erb +++ b/web/app/views/sessions/failure.html.erb @@ -1,14 +1,15 @@

Access denied

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

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

+

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

<% else %>

Something went wrong signing in.

<%= @message %>

<% end %> <% if current_staff %> - <%= button_to "Sign out", logout_path, method: :delete, class: "btn w-full" %> + <%= link_to "Go to your API settings", you_api_path, class: "btn btn-primary w-full" %> + <%= button_to "Sign out", logout_path, method: :delete, class: "btn w-full mt-2" %> <% else %> <%= link_to "Back to sign in", login_path, class: "btn w-full" %> <% end %> diff --git a/web/app/views/you/api/show.html.erb b/web/app/views/you/api/show.html.erb new file mode 100644 index 00000000..4ab3ec2b --- /dev/null +++ b/web/app/views/you/api/show.html.erb @@ -0,0 +1,32 @@ +<% content_for :page_title, "API access" %> + +
+
+ +
+

Your account

+ +
+
+ <% if current_profile&.image_url %> + + <% else %> + <%= member_id.first %> + <% end %> + + <%= current_profile&.display_name.presence || member_id %> + <%= member_id %> + +
+ +
+
+ Channels you are in + "> + <%= @rooms.any? ? number_with_delimiter(@rooms.size) : "n/a" %> + +
+
+
+
+
diff --git a/web/config/routes.rb b/web/config/routes.rb index 58886d15..f4fa160a 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -10,6 +10,10 @@ get "dev/be/:user_id", to: "dev_sessions#create", as: :dev_be end + namespace :you do + get "api", to: "api#show", as: :api + end + namespace :fd do root to: "fire#show" post "cases/merge", to: "merges#create", as: :merge_cases diff --git a/web/test/controllers/home_controller_test.rb b/web/test/controllers/home_controller_test.rb index 809b7bf1..5b4132db 100644 --- a/web/test/controllers/home_controller_test.rb +++ b/web/test/controllers/home_controller_test.rb @@ -22,12 +22,12 @@ class HomeControllerTest < ActionDispatch::IntegrationTest assert_redirected_to login_path end - test "a staff row with no roles cannot sign in, so it never reaches the dashboard" do + test "a staff row with no roles signs in, and still never reaches the dashboard" do staff = Staff.create!(user_id: "UTESTNONE1") sign_in_as(staff) - assert_redirected_to auth_failure_path(message: "not_allowlisted") + assert_redirected_to you_api_path get root_path - assert_redirected_to login_path + assert_redirected_to auth_failure_path(message: "not_allowlisted") end end diff --git a/web/test/controllers/sessions_controller_test.rb b/web/test/controllers/sessions_controller_test.rb index 353e3113..d17689e8 100644 --- a/web/test/controllers/sessions_controller_test.rb +++ b/web/test/controllers/sessions_controller_test.rb @@ -20,23 +20,23 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_equal "UTESTALLOWED", session[:user_id] end - test "a staff row with no grant is rejected like a stranger" do + test "a staff row with no grant gets the member area, not the dashboard" do Staff.create!(user_id: "UTESTNOGRANT", community_manager: false) mock_hca_auth("UTESTNOGRANT") get "/auth/hackclub/callback" - assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] + assert_redirected_to you_api_path + assert_equal "UTESTNOGRANT", session[:user_id] end - test "unknown slack id is rejected" do + test "unknown slack id gets the member area too" do mock_hca_auth("UNOTALLOWED") get "/auth/hackclub/callback" - assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] + assert_redirected_to you_api_path + assert_equal "UNOTALLOWED", session[:user_id] end test "missing slack_id claim is rejected" do @@ -49,7 +49,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest get "/auth/hackclub/callback" - assert_redirected_to auth_failure_path(message: "not_allowlisted") + assert_redirected_to auth_failure_path(message: "no_slack_id") + assert_nil session[:user_id] end test "logout clears the session" do diff --git a/web/test/integration/member_area_test.rb b/web/test/integration/member_area_test.rb new file mode 100644 index 00000000..932da661 --- /dev/null +++ b/web/test/integration/member_area_test.rb @@ -0,0 +1,120 @@ +require "test_helper" + +class MemberAreaTest < ActionDispatch::IntegrationTest + setup do + @member = Staff.create!(user_id: "UMEMBER1") + @staff = Staff.create!(user_id: "UBOSS1", community_manager: true) + end + + def gated_paths + [root_path, fd_root_path, fd_cases_path, fd_members_path, fd_settings_path, + fd_audit_path, fd_decisions_path, fd_search_path, channels_path, engine_path, + acquisition_journey_path] + end + + test "a member with no role signs in and lands on their own page" do + sign_in_as(@member) + + assert_redirected_to you_api_path + follow_redirect! + assert_response :success + end + + test "a member with no role holds no permission at all" do + assert_nil @member.role + Fd::Permission.keys.each do |key| + assert_not @member.may?(key), "a member with no role must not hold #{key}" + end + end + + test "every other page is still shut to a member with no role" do + sign_in_as(@member) + + gated_paths.each do |path| + get path + assert_redirected_to auth_failure_path(message: "not_allowlisted"), + "#{path} let a member with no role through" + end + end + + test "a member with no role cannot write to fire engine either" do + kase = make_case + + sign_in_as(@member) + post fd_case_claim_path(kase) + + assert_redirected_to auth_failure_path(message: "not_allowlisted") + assert_empty kase.reload.assignees + end + + test "a json request from a member with no role is refused, not redirected" do + sign_in_as(@member) + get fd_search_path(format: :json) + + assert_response :unauthorized + end + + test "signed out, the member page sends you to sign in" do + get you_api_path + + assert_redirected_to login_path + end + + test "signing out shuts the member page again" do + sign_in_as(@member) + get you_api_path + assert_response :success + + delete logout_path + get you_api_path + + assert_redirected_to login_path + end + + test "an identity that is not a slack id is given no session at all" do + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:hackclub] = OmniAuth::AuthHash.new( + provider: "hackclub", uid: "ident!nonsense", info: {}, + extra: { raw_info: { "slack_id" => "../../etc/passwd" } } + ) + get "/auth/hackclub/callback" + + assert_redirected_to auth_failure_path(message: "no_slack_id") + + get you_api_path + assert_redirected_to login_path + end + + test "the member page shows no fire engine and no analytics in the rail" do + sign_in_as(@member) + get you_api_path + + assert_select ".rail-item[href=?]", you_api_path + assert_select ".rail-item[href=?]", fd_cases_path, count: 0 + assert_select ".rail-item[href=?]", channels_path, count: 0 + assert_select ".rail-item[href=?]", engine_path, count: 0 + assert_select ".rail-find", { count: 0 }, "the search palette is firefighters only" + end + + test "a firefighter keeps their whole rail and gains the account section" do + sign_in_as(@staff) + get you_api_path + + assert_response :success + assert_select ".rail-item[href=?]", you_api_path + assert_select ".rail-item[href=?]", fd_cases_path + end + + test "signing in as a firefighter still lands on the dashboard, not the member page" do + sign_in_as(@staff) + + assert_redirected_to root_path + end + + test "the sign in page sends a signed in member on rather than looping" do + sign_in_as(@member) + get login_path + + assert_redirected_to you_api_path + end +end diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb index 5a38f5fe..fe92c3be 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -8,6 +8,8 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest INSIDE = %i[root_path fd_root_path fd_members_path fd_decisions_path fd_settings_path].freeze + MEMBER = %w[you/api].freeze + setup do Rails.application.eager_load! @me = Staff.create!(user_id: "UNOROLE", community_manager: false) @@ -17,13 +19,20 @@ def self.controllers Rails.application.routes.routes.filter_map { |route| route.defaults[:controller] }.uniq end + def filters_of(name) + "#{name}_controller".camelize.constantize._process_action_callbacks.map(&:filter) + end + def guarded?(name) - "#{name}_controller".camelize.constantize._process_action_callbacks - .any? { |callback| callback.filter == :require_staff } + filters_of(name).include?(:require_staff) + end + + def member_guarded?(name) + filters_of(name).include?(:require_a_member) end def guarded_controllers - (self.class.controllers - OPEN).select { |name| guarded?(name) } + (self.class.controllers - OPEN).select { |name| guarded?(name) || member_guarded?(name) } end test "the only routes open to the world are signing in and the health check" do @@ -31,12 +40,24 @@ def guarded_controllers "a route opened up or closed, so this test needs updating" end - test "every controller behind the login demands a role, not merely a session" do - (self.class.controllers - OPEN).each do |name| + test "every controller behind the login demands a role, bar the member area" do + (self.class.controllers - OPEN - MEMBER).each do |name| assert guarded?(name), "#{name} lets anybody through" + assert_not member_guarded?(name), "#{name} settles for a session where a role is needed" end end + test "the member area demands a session and never a role, and is only what is listed" do + MEMBER.each do |name| + assert member_guarded?(name), "#{name} lets anybody through" + assert_not guarded?(name), "#{name} still demands a role, so it is not a member page" + end + + assert_equal MEMBER.sort, + (self.class.controllers - OPEN).reject { |name| guarded?(name) }.sort, + "a controller dropped its role check, so this test needs updating" + end + test "the turbo routes are open because they carry nothing but a go back" do TURBO.each do |path| get path @@ -45,11 +66,17 @@ def guarded_controllers end end - test "holding no grant means no session, whatever the staff table says" do + test "holding no grant opens the member area and nothing behind it" do sign_in_as(@me) - assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] + assert_redirected_to you_api_path + assert_equal "UNOROLE", session[:user_id] + + INSIDE.each do |path| + get send(path) + assert_redirected_to auth_failure_path(message: "not_allowlisted"), + "#{path} took a session for a role" + end end test "a live grant is enough on its own, with no staff row behind it" do @@ -64,21 +91,24 @@ def guarded_controllers assert_response :success end - test "somebody unknown to the staff table is refused the same way" do + test "somebody unknown to the staff table gets the same member area and no more" do sign_in_as(Staff.new(user_id: "USTRANGER")) + assert_redirected_to you_api_path + get fd_cases_path assert_redirected_to auth_failure_path(message: "not_allowlisted") - assert_nil session[:user_id] end test "the refusal says one thing, and offers the way back" do sign_in_as(@me) + get root_path follow_redirect! assert_response :success assert_select "h1", text: "Access denied" assert_select "p", text: /You are not allowlisted/ - assert_select "a[href=?]", login_path + assert_select "a[href=?]", you_api_path, 1, "the way out is their own page" + assert_select "form[action=?]", logout_path assert_select ".auth-alt", count: 0 end @@ -111,15 +141,16 @@ def guarded_controllers assert_redirected_to auth_failure_path(message: "not_allowlisted") end - test "a stale session lands on the sign in page rather than bouncing forever" do + test "a stale session lands on the member area rather than bouncing forever" do grant = Fd::AccessGrant.give!("UNOROLE", role: "firefighter", by: "UBOSS") sign_in_as(@me) grant.take_back!(by: "UBOSS") get login_path + assert_redirected_to you_api_path + follow_redirect! assert_response :success - assert_select "h1", text: /sign in/i end test "the sign in switch for development is not routed anywhere else" do From f50965b740327ca712988719a5abaf017d89eeb0 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 00:58:20 +0300 Subject: [PATCH 06/16] web: add the permissions list on /you/api --- db/capabilities.yml | 4 + web/app/assets/tailwind/application.css | 29 +++++ web/app/controllers/you/api_controller.rb | 1 + .../controllers/you/consents_controller.rb | 27 +++++ web/app/models/api/capability.rb | 17 +++ web/app/models/api/consent.rb | 36 ++++++ web/app/models/api/consent_log.rb | 5 + web/app/views/you/api/show.html.erb | 35 +++++- web/config/routes.rb | 1 + web/test/integration/member_consent_test.rb | 113 ++++++++++++++++++ web/test/integration/who_gets_in_test.rb | 2 +- 11 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 db/capabilities.yml create mode 100644 web/app/controllers/you/consents_controller.rb create mode 100644 web/app/models/api/capability.rb create mode 100644 web/app/models/api/consent.rb create mode 100644 web/app/models/api/consent_log.rb create mode 100644 web/test/integration/member_consent_test.rb diff --git a/db/capabilities.yml b/db/capabilities.yml new file mode 100644 index 00000000..a8c8abde --- /dev/null +++ b/db/capabilities.yml @@ -0,0 +1,4 @@ +capabilities: + channel_manager: + label: Channel manager lookups + covers: Apps can ask whether you manage a given channel. They are told yes, no, or nothing. diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index 8baea713..9610b9a8 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -2755,6 +2755,35 @@ border-bottom: 0; } + .fbox > .rows > .cap-row { + align-items: center; + gap: 14px; + padding: 11px; + } + + .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; + } + .row-k { color: var(--ink-3); } .row-v { diff --git a/web/app/controllers/you/api_controller.rb b/web/app/controllers/you/api_controller.rb index abb5f140..bef1a81c 100644 --- a/web/app/controllers/you/api_controller.rb +++ b/web/app/controllers/you/api_controller.rb @@ -1,6 +1,7 @@ module You class ApiController < BaseController def show + @consents = Api::Consent.states_for(member_id) @rooms = SlackScan.channels(member_id) end end diff --git a/web/app/controllers/you/consents_controller.rb b/web/app/controllers/you/consents_controller.rb new file mode 100644 index 00000000..67c3b0bf --- /dev/null +++ b/web/app/controllers/you/consents_controller.rb @@ -0,0 +1,27 @@ +module You + class ConsentsController < BaseController + def update + return redirect_to(you_api_path, alert: turned_off) if Fd::Flag.off?(:public_api) + + key = params[:capability].to_s + Api::Capability.fetch(key) + granted = params[:on] == "1" + Api::Consent.set!(member_id, key, granted, via: "dashboard") + + redirect_to you_api_path, notice: said(key, granted) + rescue Api::Capability::Unknown => e + redirect_to you_api_path, alert: e.message + end + + private + + def turned_off + "#{Fd::Flag.label(:public_api).downcase} is turned off" + end + + def said(key, granted) + named = Api::Capability.label(key).downcase + granted ? "Opted in to #{named}" : "Opted out of #{named}" + end + end +end diff --git a/web/app/models/api/capability.rb b/web/app/models/api/capability.rb new file mode 100644 index 00000000..25c1b41c --- /dev/null +++ b/web/app/models/api/capability.rb @@ -0,0 +1,17 @@ +module Api + class Capability + class Unknown < ArgumentError; end + + TABLE = YAML.load_file(Rails.root.join("../db/capabilities.yml")) + .fetch("capabilities").freeze + KEYS = TABLE.keys.freeze + + def self.fetch(key) + TABLE.fetch(key.to_s) { raise Unknown, "#{key} is not a capability" } + end + + def self.label(key) = fetch(key).fetch("label") + + def self.covers(key) = fetch(key).fetch("covers") + end +end diff --git a/web/app/models/api/consent.rb b/web/app/models/api/consent.rb new file mode 100644 index 00000000..255f00ae --- /dev/null +++ b/web/app/models/api/consent.rb @@ -0,0 +1,36 @@ +module Api + class Consent < ApplicationRecord + self.table_name = "api.consent" + self.primary_key = [:user_id, :capability] + + GRANTED = "granted".freeze + WITHHELD = "withheld".freeze + + def self.granted?(user_id, capability) + exists?(user_id: user_id, capability: capability, state: GRANTED) + end + + def self.states_for(user_id) + where(user_id: user_id).pluck(:capability, :state).to_h + end + + def self.granted_count(user_id) + where(user_id: user_id, state: GRANTED).count + end + + def self.set!(user_id, capability, granted, via:) + state = granted ? GRANTED : WITHHELD + + transaction do + row = find_or_initialize_by(user_id: user_id, capability: capability) + row.state = state + row.changed_at = Time.current + row.changed_via = via + row.first_granted_at ||= row.changed_at if granted + row.save! + ConsentLog.create!(user_id: user_id, capability: capability, state: state, via: via) + row + end + end + end +end diff --git a/web/app/models/api/consent_log.rb b/web/app/models/api/consent_log.rb new file mode 100644 index 00000000..da849962 --- /dev/null +++ b/web/app/models/api/consent_log.rb @@ -0,0 +1,5 @@ +module Api + class ConsentLog < ApplicationRecord + self.table_name = "api.consent_log" + end +end diff --git a/web/app/views/you/api/show.html.erb b/web/app/views/you/api/show.html.erb index 4ab3ec2b..6441c7fe 100644 --- a/web/app/views/you/api/show.html.erb +++ b/web/app/views/you/api/show.html.erb @@ -1,7 +1,34 @@ <% content_for :page_title, "API access" %> +<% api_off = Fd::Flag.off?(:public_api) %> +<% granted = Api::Capability::KEYS.count { |key| @consents[key] == Api::Consent::GRANTED } %>
-
+
+
+
+ What apps may ask about you + <%= granted %> of <%= Api::Capability::KEYS.size %> +
+
+ <% Api::Capability::KEYS.each do |key| %> + <% on = @consents[key] == Api::Consent::GRANTED %> +
+ + <%= Api::Capability.label(key) %> + <%= Api::Capability.covers(key) %> + + + <%= button_to on ? "Opt out" : "Opt in", + you_consent_path(capability: key, on: on ? "0" : "1"), + method: :patch, disabled: api_off, + class: "btn #{'btn-primary' unless on} #{'is-off' if api_off}", + form: { class: "contents" } %> + +
+ <% end %> +
+
+

Your account

@@ -20,6 +47,12 @@
+
+ Opted in to + "> + <%= granted.zero? ? "nothing" : "#{granted} of #{Api::Capability::KEYS.size}" %> + +
Channels you are in "> diff --git a/web/config/routes.rb b/web/config/routes.rb index f4fa160a..3c617a5a 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -12,6 +12,7 @@ namespace :you do get "api", to: "api#show", as: :api + resource :consent, only: [:update], controller: "consents" end namespace :fd do diff --git a/web/test/integration/member_consent_test.rb b/web/test/integration/member_consent_test.rb new file mode 100644 index 00000000..fc26ac6b --- /dev/null +++ b/web/test/integration/member_consent_test.rb @@ -0,0 +1,113 @@ +require "test_helper" + +class MemberConsentTest < ActionDispatch::IntegrationTest + CAP = "channel_manager".freeze + + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @member = Staff.create!(user_id: "UMEMBER2") + sign_in_as(@member) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def flip(on, **extra) + patch you_consent_path(capability: CAP, on: on, **extra) + end + + def state + Api::Consent.find_by(user_id: @member.user_id, capability: CAP)&.state + end + + test "opting in writes the consent and exactly one log line" do + assert_difference -> { Api::ConsentLog.count }, 1 do + flip("1") + end + + assert_equal "granted", state + assert_equal "dashboard", Api::ConsentLog.last.via + assert_equal @member.user_id, Api::ConsentLog.last.user_id + end + + test "opting out again withholds it and logs the second move" do + flip("1") + assert_difference -> { Api::ConsentLog.count }, 1 do + flip("0") + end + + assert_equal "withheld", state + assert_equal %w[granted withheld], Api::ConsentLog.order(:at, :id).pluck(:state) + end + + test "the first grant is remembered even after opting out" do + flip("1") + first = Api::Consent.find_by(user_id: @member.user_id, capability: CAP).first_granted_at + flip("0") + + assert_equal first, Api::Consent.find_by(user_id: @member.user_id, capability: CAP) + .first_granted_at + end + + test "a member can only ever move their own row" do + flip("1", user_id: "UVICTIM", member_id: "UVICTIM") + + assert_equal "granted", state + assert_nil Api::Consent.find_by(user_id: "UVICTIM") + assert_empty Api::ConsentLog.where(user_id: "UVICTIM") + end + + test "a capability nobody declared is refused and writes nothing" do + assert_no_difference -> { Api::ConsentLog.count } do + patch you_consent_path(capability: "read_my_email", on: "1") + end + + assert_redirected_to you_api_path + assert_match(/not a capability/, flash[:alert]) + assert_empty Api::Consent.all + end + + test "nothing moves while the public api is turned off" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + + assert_no_difference -> { Api::ConsentLog.count } do + flip("1") + end + + assert_nil state + assert_match(/turned off/, flash[:alert]) + end + + test "the page offers opting in, then opting out, and counts what is on" do + get you_api_path + + assert_select ".cap-row .btn", text: "Opt in" + assert_select ".fbox > .ft", text: /0 of 1/ + + flip("1") + get you_api_path + + assert_select ".cap-row .btn", text: "Opt out" + assert_select ".fbox > .ft", text: /1 of 1/ + end + + test "the switch is dead on the page while the public api is off" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + get you_api_path + + assert_select ".cap-row .btn.is-off" + assert_select ".cap-row .btn[disabled]" + end + + test "signed out, nobody can move consent at all" do + delete logout_path + + assert_no_difference -> { Api::ConsentLog.count } do + flip("1") + end + + assert_redirected_to login_path + end +end diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb index fe92c3be..c0a8d80a 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -8,7 +8,7 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest INSIDE = %i[root_path fd_root_path fd_members_path fd_decisions_path fd_settings_path].freeze - MEMBER = %w[you/api].freeze + MEMBER = %w[you/api you/consents].freeze setup do Rails.application.eager_load! From 0f97ff69ce0a7794b8133623911ed4297cf67493 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 01:04:35 +0300 Subject: [PATCH 07/16] web(fd): show consent changes in the audit log --- web/app/helpers/fd_helper.rb | 3 ++ web/app/models/api/capability.rb | 4 +++ web/app/models/fd/deeds.rb | 53 ++++++++++++++++++++++++++------ web/test/models/fd/deeds_test.rb | 44 ++++++++++++++++++++++---- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/web/app/helpers/fd_helper.rb b/web/app/helpers/fd_helper.rb index d8e5a510..5d9be71d 100644 --- a/web/app/helpers/fd_helper.rb +++ b/web/app/helpers/fd_helper.rb @@ -1149,6 +1149,8 @@ def acted_label(at) "decision/dropped" => "Dropped", "decision/settled" => "Settled", "decision/superseded" => "Retired", + "consent/granted" => "Opted in to", + "consent/withheld" => "Opted out of", "decision_thread/attached" => "Linked a thread to", "decision_thread/detached" => "Unlinked a thread from", "grant/granted" => "Gave access to", @@ -1213,6 +1215,7 @@ def deed_link(deed) case deed.kind when "case" then link_to deed.about, fd_case_path(deed.id), class: "lnk" when "decision" then link_to deed.about, fd_decision_path(deed.id), class: "lnk" + when "capability" then deed.about else member_link(deed.id) end end diff --git a/web/app/models/api/capability.rb b/web/app/models/api/capability.rb index 25c1b41c..40f13bed 100644 --- a/web/app/models/api/capability.rb +++ b/web/app/models/api/capability.rb @@ -13,5 +13,9 @@ def self.fetch(key) 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/fd/deeds.rb b/web/app/models/fd/deeds.rb index ff6b3771..ac383152 100644 --- a/web/app/models/fd/deeds.rb +++ b/web/app/models/fd/deeds.rb @@ -6,9 +6,12 @@ class Deeds VIEWS = { "all" => { tab: "Everything", head: "Everything anyone did" }, "audit" => { tab: "Firefighters", head: "Everything firefighters did" }, - "read" => { tab: "Identity reads", head: "Every identity read" } + "read" => { tab: "Identity reads", head: "Every identity read" }, + "api" => { tab: "API", head: "Everything anyone did to the API" } }.freeze + VIA = { "dashboard" => "from the dashboard", "command" => "from Slack" }.freeze + Row = Struct.new(:at, :event, :kind, :id, :about, :who, :said, :actor, keyword_init: true) ON_CASE = %w[case participant assignee thread citation].freeze @@ -50,7 +53,7 @@ def picked(select = "kind, id, at") return AuditEntry.connection.select_all("SELECT 0 AS found WHERE false") if nothing_asked? sql = <<~SQL - WITH picked AS (#{audit_side}#{reads_side}) + WITH picked AS (#{union}) SELECT #{select} FROM picked SQL sql += "ORDER BY at DESC LIMIT :limit OFFSET :offset" unless select.start_with?("count") @@ -60,8 +63,16 @@ def picked(select = "kind, id, at") ) end + def union + "#{audit_side}#{reads_side}#{consent_side}" + end + def nothing_asked? - audit_side.strip.empty? && reads_side.strip.empty? + union.strip.empty? + end + + def wanted?(name) + @view == "all" || @view == name end def tallied @@ -69,7 +80,7 @@ def tallied return counted if nothing_asked? sql = <<~SQL - WITH picked AS (#{audit_side}#{reads_side}) + WITH picked AS (#{union}) SELECT kind, count(*) AS found FROM picked GROUP BY kind SQL found = AuditEntry.connection.select_all( @@ -80,7 +91,7 @@ def tallied end def audit_side - return "" if @view == "read" + return "" unless wanted?("audit") return "" if @only && @only != READ && Permission.events(@only).empty? return "" if @only == READ @@ -93,19 +104,32 @@ def audit_side end def reads_side - return "" if @view == "audit" + return "" unless wanted?("read") return "" if @only && @only != READ mine = @user_id ? "AND l.actor_id = :who" : "" - lead = audit_side.empty? ? "" : "UNION ALL" <<~SQL - #{lead} + #{audit_side.empty? ? '' : 'UNION ALL'} SELECT 'read' AS kind, l.id AS id, l.looked_at AS at FROM access_log l WHERE l.looked_at >= :since AND l.field_class = 'identity' #{mine} SQL end + def consent_side + return "" unless wanted?("api") + return "" if @only + + mine = @user_id ? "AND c.user_id = :who" : "" + lead = audit_side.empty? && reads_side.empty? ? "" : "UNION ALL" + <<~SQL + #{lead} + SELECT 'api' AS kind, c.id AS id, c.at AS at + FROM api.consent_log c + WHERE c.at >= :since #{mine} + SQL + end + def only_clause return "" unless @only @@ -133,6 +157,7 @@ def built chosen = picked.to_a @picked_ids = chosen.select { |row| row["kind"] == "audit" }.map { |row| row["id"] } read_rows = reads_for(chosen.select { |row| row["kind"] == "read" }.map { |row| row["id"] }) + consent_rows = consents_for(chosen.select { |row| row["kind"] == "api" }.map { |row| row["id"] }) found = entries @actions = Action.where(id: ids(found, "action")).index_by(&:id) @@ -143,7 +168,17 @@ def built .pluck(:id, :title).to_h made = found.map { |row| row_for(row).tap { |one| one.actor = row.actor_user_id } } - (made + read_rows).sort_by { |row| -row.at.to_i } + (made + read_rows + consent_rows).sort_by { |row| -row.at.to_i } + end + + def consents_for(ids) + return [] if ids.empty? + + ::Api::ConsentLog.where(id: ids).map do |log| + Row.new(at: log.at, event: "consent/#{log.state}", kind: "capability", + about: ::Api::Capability.said(log.capability), actor: log.user_id, + said: VIA.fetch(log.via, log.via)) + end end def reads_for(ids) diff --git a/web/test/models/fd/deeds_test.rb b/web/test/models/fd/deeds_test.rb index b6f6acaa..6bc07e4b 100644 --- a/web/test/models/fd/deeds_test.rb +++ b/web/test/models/fd/deeds_test.rb @@ -120,12 +120,43 @@ def both_kinds looked_at: 2.days.ago) end - test "a view keeps only its own source, and no view keeps nothing" do + def all_three both_kinds + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + end - assert_equal %w[case/opened identity/read], deeds.map(&:event).sort + test "a view keeps only its own source, and no view keeps nothing" do + all_three + + assert_equal %w[case/opened consent/granted identity/read], deeds.map(&:event).sort assert_equal ["case/opened"], deeds(view: "audit").map(&:event) assert_equal ["identity/read"], deeds(view: "read").map(&:event) + assert_equal ["consent/granted"], deeds(view: "api").map(&:event) + end + + test "a consent change names the capability and how it was made, not a member link" do + Api::Consent.set!(WHO, "channel_manager", true, via: "command") + + row = deeds(view: "api").sole + assert_equal ["capability", "channel manager lookups"], [row.kind, row.about] + assert_equal ["from Slack", WHO], [row.said, row.actor] + assert_nil row.id, "a consent row links nothing, it is about the person who made it" + end + + test "opting back out reads as its own line, and both survive" do + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + Api::Consent.set!(WHO, "channel_manager", false, via: "dashboard") + + assert_equal ["consent/granted", "consent/withheld"], deeds(view: "api").map(&:event).sort, + "one line each way, whatever order a shared timestamp puts them in" + end + + test "consent changes are asked for by nobody looking at one permission" do + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + audit("case", make_case.id, "opened") + + assert_equal ["case/opened"], deeds(only: "case.open").map(&:event) + assert_empty deeds(only: "identity.read") end test "an unknown view falls back to everything rather than to nothing" do @@ -137,17 +168,18 @@ def both_kinds end test "the per-view counts add up to the unfiltered total" do - both_kinds + all_three counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals assert_equal 1, counted["audit"] assert_equal 1, counted["read"] - assert_equal counted["all"], counted["audit"] + counted["read"] + assert_equal 1, counted["api"] + assert_equal counted["all"], counted["audit"] + counted["read"] + counted["api"] assert_equal Fd::Deeds.new(WHO, since: 30.days.ago).total, counted["all"] end test "counting a view agrees with paging it" do - both_kinds + all_three counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals Fd::Deeds::VIEWS.each_key do |key| @@ -159,7 +191,7 @@ def both_kinds test "a view still counts every source when there is nothing to count" do counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals - assert_equal({ "audit" => 0, "read" => 0, "all" => 0 }, counted) + assert_equal({ "audit" => 0, "read" => 0, "api" => 0, "all" => 0 }, counted) end test "the members it mentions are the ones a name is needed for" do From 4f1af4a8291ca13fc5f122b4ed49f1682690dd55 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 01:15:14 +0300 Subject: [PATCH 08/16] web: add the tokens tab on /you/api --- db/migrations/0056_api_event_log.sql | 10 ++ web/app/assets/tailwind/application.css | 53 +++++++- web/app/controllers/you/api_controller.rb | 4 + web/app/controllers/you/tokens_controller.rb | 46 +++++++ web/app/helpers/fd_helper.rb | 2 + web/app/models/api/event.rb | 9 ++ web/app/models/api/setting.rb | 24 ++++ web/app/models/api/token.rb | 56 +++++++++ web/app/models/fd/deeds.rb | 53 ++++++-- web/app/views/you/api/_kill.html.erb | 27 +++++ web/app/views/you/api/_mint.html.erb | 54 +++++++++ web/app/views/you/api/show.html.erb | 120 +++++++++++++++---- web/config/routes.rb | 1 + web/test/integration/member_consent_test.rb | 4 +- web/test/integration/who_gets_in_test.rb | 2 +- 15 files changed, 432 insertions(+), 33 deletions(-) create mode 100644 db/migrations/0056_api_event_log.sql create mode 100644 web/app/controllers/you/tokens_controller.rb create mode 100644 web/app/models/api/event.rb create mode 100644 web/app/models/api/setting.rb create mode 100644 web/app/models/api/token.rb create mode 100644 web/app/views/you/api/_kill.html.erb create mode 100644 web/app/views/you/api/_mint.html.erb diff --git a/db/migrations/0056_api_event_log.sql b/db/migrations/0056_api_event_log.sql new file mode 100644 index 00000000..a6c2df72 --- /dev/null +++ b/db/migrations/0056_api_event_log.sql @@ -0,0 +1,10 @@ +CREATE TABLE api.event_log ( + id bigserial PRIMARY KEY, + actor_user_id text, + verb text NOT NULL, + subject text, + detail text, + at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX event_log_at_idx ON api.event_log (at DESC); diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index 9610b9a8..29b3618b 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -2755,10 +2755,17 @@ border-bottom: 0; } - .fbox > .rows > .cap-row { + .cap-row { + display: flex; align-items: center; + justify-content: space-between; gap: 14px; - padding: 11px; + padding: 11px 14px; + border-bottom: 1px solid var(--line-soft); + } + + .cap-row:last-child { + border-bottom: 0; } .cap-two { @@ -2784,6 +2791,48 @@ flex: none; } + .btn-crit { + background-color: var(--sev-crit); + border-color: var(--sev-crit); + color: var(--accent-on); + } + + .btn-crit:hover { + background-color: var(--sev-crit-deep); + border-color: var(--sev-crit-deep); + color: var(--accent-on); + } + + .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); + } + .row-k { color: var(--ink-3); } .row-v { diff --git a/web/app/controllers/you/api_controller.rb b/web/app/controllers/you/api_controller.rb index bef1a81c..16c0165b 100644 --- a/web/app/controllers/you/api_controller.rb +++ b/web/app/controllers/you/api_controller.rb @@ -1,7 +1,11 @@ module You class ApiController < BaseController + TABS = { "permissions" => "Permissions", "tokens" => "Tokens" }.freeze + def show + @tab = TABS.key?(params[:tab]) ? params[:tab] : "permissions" @consents = Api::Consent.states_for(member_id) + @tokens = Api::Token.for_owner(member_id) @rooms = SlackScan.channels(member_id) end end diff --git a/web/app/controllers/you/tokens_controller.rb b/web/app/controllers/you/tokens_controller.rb new file mode 100644 index 00000000..1c056189 --- /dev/null +++ b/web/app/controllers/you/tokens_controller.rb @@ -0,0 +1,46 @@ +module You + class TokensController < BaseController + def create + return refuse(turned_off) if Fd::Flag.off?(:public_api) + return refuse("name what the token is for") if params[:name].to_s.strip.empty? + + token, @secret = Api::Token.mint!(member_id, params[:name]) + Api::Event.record!("token_minted", actor: member_id, subject: token.shown, + detail: token.name) + + @token = token + show_again + rescue Api::Token::TooMany + refuse("you already hold #{Api::Setting.value('tokens_per_owner')} tokens") + end + + def destroy + token = Api::Token.live.find_by(id: params[:id], owner_user_id: member_id) + return refuse("that token is not yours") if token.nil? + + token.revoke!(by: member_id) + Api::Event.record!("token_revoked", actor: member_id, subject: token.shown, + detail: token.name) + + redirect_to you_api_path(tab: "tokens"), notice: "Revoked #{token.name}" + end + + private + + def turned_off + "#{Fd::Flag.label(:public_api).downcase} is turned off" + end + + def refuse(said) + redirect_to you_api_path(tab: "tokens"), alert: said + end + + def show_again + @tab = "tokens" + @consents = Api::Consent.states_for(member_id) + @tokens = Api::Token.for_owner(member_id) + @rooms = SlackScan.channels(member_id) + render "you/api/show" + end + end +end diff --git a/web/app/helpers/fd_helper.rb b/web/app/helpers/fd_helper.rb index 5d9be71d..996442fc 100644 --- a/web/app/helpers/fd_helper.rb +++ b/web/app/helpers/fd_helper.rb @@ -1151,6 +1151,8 @@ def acted_label(at) "decision/superseded" => "Retired", "consent/granted" => "Opted in to", "consent/withheld" => "Opted out of", + "api/token_minted" => "Generated a token,", + "api/token_revoked" => "Revoked a token,", "decision_thread/attached" => "Linked a thread to", "decision_thread/detached" => "Unlinked a thread from", "grant/granted" => "Gave access to", 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/setting.rb b/web/app/models/api/setting.rb new file mode 100644 index 00000000..d35808a4 --- /dev/null +++ b/web/app/models/api/setting.rb @@ -0,0 +1,24 @@ +module Api + class Setting < ApplicationRecord + self.table_name = "api.setting" + self.primary_key = "key" + + DEFAULTS = { + "rate_per_minute" => 100, + "batch_max" => 100, + "tokens_per_owner" => 3 + }.freeze + + def self.value(key) + DEFAULTS.fetch(key.to_s) + find_by(key: key.to_s)&.value || DEFAULTS.fetch(key.to_s) + end + + def self.set!(key, value, by:) + DEFAULTS.fetch(key.to_s) + row = find_or_initialize_by(key: key.to_s) + row.update!(value: value, changed_by: by, changed_at: Time.current) + row + end + end +end diff --git a/web/app/models/api/token.rb b/web/app/models/api/token.rb new file mode 100644 index 00000000..80b6f891 --- /dev/null +++ b/web/app/models/api/token.rb @@ -0,0 +1,56 @@ +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 + + scope :live, -> { where(revoked_at: nil) } + + def self.for_owner(user_id) + where(owner_user_id: user_id).order(revoked_at: :asc, created_at: :desc) + end + + def self.room_for?(user_id) + live.where(owner_user_id: user_id).count < Setting.value("tokens_per_owner") + end + + def self.digest_of(secret) + Digest::SHA256.hexdigest(secret.to_s) + end + + def self.secret + LEAD + Array.new(LENGTH) { ALPHABET[SecureRandom.random_number(ALPHABET.size)] }.join + end + + def self.mint!(owner_user_id, name) + raise TooMany unless room_for?(owner_user_id) + + key = secret + row = create!(owner_user_id: owner_user_id, name: name.to_s.strip.first(MAX_NAME), + prefix: key.first(LEAD.length + SHOWN), digest: digest_of(key)) + [row, key] + end + + def revoked? = revoked_at.present? + + def revoke!(by:) + update!(revoked_at: Time.current, revoked_by: by) + end + + def rate + rate_limit || Setting.value("rate_per_minute") + end + + def shown + "#{prefix}…" + end + end +end diff --git a/web/app/models/fd/deeds.rb b/web/app/models/fd/deeds.rb index ac383152..bf855744 100644 --- a/web/app/models/fd/deeds.rb +++ b/web/app/models/fd/deeds.rb @@ -12,6 +12,10 @@ class Deeds VIA = { "dashboard" => "from the dashboard", "command" => "from Slack" }.freeze + KIND_VIEWS = { + "audit" => "audit", "read" => "read", "consent" => "api", "event" => "api" + }.freeze + Row = Struct.new(:at, :event, :kind, :id, :about, :who, :said, :actor, keyword_init: true) ON_CASE = %w[case participant assignee thread citation].freeze @@ -64,7 +68,7 @@ def picked(select = "kind, id, at") end def union - "#{audit_side}#{reads_side}#{consent_side}" + "#{audit_side}#{reads_side}#{consent_side}#{event_side}" end def nothing_asked? @@ -77,7 +81,7 @@ def wanted?(name) def tallied counted = VIEWS.keys.excluding("all").index_with(0) - return counted if nothing_asked? + return counted.merge("all" => 0) if nothing_asked? sql = <<~SQL WITH picked AS (#{union}) @@ -86,7 +90,10 @@ def tallied found = AuditEntry.connection.select_all( AuditEntry.sanitize_sql([sql, { since: @since, who: @user_id }]) ) - found.each { |row| counted[row["kind"]] = row["found"].to_i } + found.each do |row| + view = KIND_VIEWS.fetch(row["kind"]) + counted[view] += row["found"].to_i + end counted.merge("all" => counted.values.sum) end @@ -121,15 +128,31 @@ def consent_side return "" if @only mine = @user_id ? "AND c.user_id = :who" : "" - lead = audit_side.empty? && reads_side.empty? ? "" : "UNION ALL" <<~SQL - #{lead} - SELECT 'api' AS kind, c.id AS id, c.at AS at + #{lead_for(audit_side, reads_side)} + SELECT 'consent' AS kind, c.id AS id, c.at AS at FROM api.consent_log c WHERE c.at >= :since #{mine} SQL end + def event_side + return "" unless wanted?("api") + return "" if @only + + mine = @user_id ? "AND e.actor_user_id = :who" : "" + <<~SQL + #{lead_for(audit_side, reads_side, consent_side)} + SELECT 'event' AS kind, e.id AS id, e.at AS at + FROM api.event_log e + WHERE e.at >= :since #{mine} + SQL + end + + def lead_for(*sides) + sides.all?(&:empty?) ? "" : "UNION ALL" + end + def only_clause return "" unless @only @@ -157,7 +180,8 @@ def built chosen = picked.to_a @picked_ids = chosen.select { |row| row["kind"] == "audit" }.map { |row| row["id"] } read_rows = reads_for(chosen.select { |row| row["kind"] == "read" }.map { |row| row["id"] }) - consent_rows = consents_for(chosen.select { |row| row["kind"] == "api" }.map { |row| row["id"] }) + consent_rows = consents_for(ids_of(chosen, "consent")) + event_rows = events_for(ids_of(chosen, "event")) found = entries @actions = Action.where(id: ids(found, "action")).index_by(&:id) @@ -168,7 +192,20 @@ def built .pluck(:id, :title).to_h made = found.map { |row| row_for(row).tap { |one| one.actor = row.actor_user_id } } - (made + read_rows + consent_rows).sort_by { |row| -row.at.to_i } + (made + read_rows + consent_rows + event_rows).sort_by { |row| -row.at.to_i } + end + + def ids_of(chosen, kind) + chosen.select { |row| row["kind"] == kind }.map { |row| row["id"] } + end + + def events_for(ids) + return [] if ids.empty? + + ::Api::Event.where(id: ids).map do |event| + Row.new(at: event.at, event: "api/#{event.verb}", kind: "capability", + about: event.detail, actor: event.actor_user_id, said: event.subject) + end end def consents_for(ids) diff --git a/web/app/views/you/api/_kill.html.erb b/web/app/views/you/api/_kill.html.erb new file mode 100644 index 00000000..f54458ef --- /dev/null +++ b/web/app/views/you/api/_kill.html.erb @@ -0,0 +1,27 @@ +<%= render layout: "fd/modal", locals: { id: "kill-#{token.id}", + title: "Revoke #{token.name}?" } do %> + <%= form_with url: you_token_path(token), method: :delete do %> + + + <% end %> +<% end %> diff --git a/web/app/views/you/api/_mint.html.erb b/web/app/views/you/api/_mint.html.erb new file mode 100644 index 00000000..40eb0ded --- /dev/null +++ b/web/app/views/you/api/_mint.html.erb @@ -0,0 +1,54 @@ +<%= render layout: "fd/modal", locals: { id: "mint-token", title: "Generate a token", + sub: "shown once", open: @secret.present? } do %> + <% if @secret.present? %> + + + <% else %> + <%= form_with url: you_tokens_path, method: :post, html: { autocomplete: "off" } do %> + + + <% end %> + <% end %> +<% end %> diff --git a/web/app/views/you/api/show.html.erb b/web/app/views/you/api/show.html.erb index 6441c7fe..f7451fa4 100644 --- a/web/app/views/you/api/show.html.erb +++ b/web/app/views/you/api/show.html.erb @@ -1,32 +1,101 @@ <% content_for :page_title, "API access" %> <% api_off = Fd::Flag.off?(:public_api) %> <% granted = Api::Capability::KEYS.count { |key| @consents[key] == Api::Consent::GRANTED } %> +<% live = @tokens.reject(&:revoked?) %> +<% cap = Api::Setting.value("tokens_per_owner") %>
-
-
- What apps may ask about you - <%= granted %> of <%= Api::Capability::KEYS.size %> -
-
- <% Api::Capability::KEYS.each do |key| %> - <% on = @consents[key] == Api::Consent::GRANTED %> -
- - <%= Api::Capability.label(key) %> - <%= Api::Capability.covers(key) %> - - - <%= button_to on ? "Opt out" : "Opt in", - you_consent_path(capability: key, on: on ? "0" : "1"), - method: :patch, disabled: api_off, - class: "btn #{'btn-primary' unless on} #{'is-off' if api_off}", - form: { class: "contents" } %> +
+
+ <% You::ApiController::TABS.each do |key, label| %> + <%= link_to you_api_path(key == "permissions" ? {} : { tab: key }), class: "view", + aria: { current: ("true" if key == @tab) } do %> + <%= label %> + + <%= key == "permissions" ? Api::Capability::KEYS.size : live.size %> -
+ <% end %> <% end %>
+ + <% if @tab == "permissions" %> +
+ <% Api::Capability::KEYS.each do |key| %> + <% on = @consents[key] == Api::Consent::GRANTED %> +
+ + <%= Api::Capability.label(key) %> + <%= Api::Capability.covers(key) %> + + + <%= button_to on ? "Opt out" : "Opt in", + you_consent_path(capability: key, on: on ? "0" : "1"), + method: :patch, disabled: api_off, + class: "btn #{'btn-primary' unless on} #{'is-off' if api_off}", + form: { class: "contents" } %> + +
+ <% end %> +
+ + <% else %> +
+ Keys you hold + + <%= live.size %> of <%= cap %> + <% if live.size < cap && !api_off %> + + <% end %> +
+ + <% if @tokens.any? %> +
+ + + + + + + + + + + + + <% @tokens.each do |token| %> + + + + + + + + + <% end %> + +
namekeyratecreatedlast used
"><%= token.name %><%= token.shown %> + <%= token.revoked? ? "n/a" : "#{token.rate}/min" %> + <%= token.created_at.strftime("%-d %b") %> + <%= token.last_used_at ? time_ago_in_words(token.last_used_at) : "never" %> + + <% if token.revoked? %> + + Revoked <%= token.revoked_at.strftime("%-d %b") %> + + <% else %> + + <% end %> +
+
+ <% else %> +
+

No tokens

+

Opting in does not need one.

+
+ <% end %> + <% end %>
@@ -53,6 +122,10 @@ <%= granted.zero? ? "nothing" : "#{granted} of #{Api::Capability::KEYS.size}" %>
+
+ Live tokens + "><%= live.size %> of <%= cap %> +
Channels you are in "> @@ -63,3 +136,10 @@
+ +<% if @tab == "tokens" && !api_off %> + <%= render "mint" %> + <% live.each do |token| %> + <%= render "kill", token: token %> + <% end %> +<% end %> diff --git a/web/config/routes.rb b/web/config/routes.rb index 3c617a5a..a5d12860 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -13,6 +13,7 @@ namespace :you do get "api", to: "api#show", as: :api resource :consent, only: [:update], controller: "consents" + resources :tokens, only: [:create, :destroy] end namespace :fd do diff --git a/web/test/integration/member_consent_test.rb b/web/test/integration/member_consent_test.rb index fc26ac6b..ab88f11e 100644 --- a/web/test/integration/member_consent_test.rb +++ b/web/test/integration/member_consent_test.rb @@ -84,13 +84,13 @@ def state get you_api_path assert_select ".cap-row .btn", text: "Opt in" - assert_select ".fbox > .ft", text: /0 of 1/ + assert_select ".facts .frow b", text: /nothing/ flip("1") get you_api_path assert_select ".cap-row .btn", text: "Opt out" - assert_select ".fbox > .ft", text: /1 of 1/ + assert_select ".facts .frow b", text: /1 of 1/ end test "the switch is dead on the page while the public api is off" do diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb index c0a8d80a..14bdd4ea 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -8,7 +8,7 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest INSIDE = %i[root_path fd_root_path fd_members_path fd_decisions_path fd_settings_path].freeze - MEMBER = %w[you/api you/consents].freeze + MEMBER = %w[you/api you/consents you/tokens].freeze setup do Rails.application.eager_load! From 6fdd0532ea79f493ec8be25a67b8a3e9557b0f2a Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 01:19:43 +0300 Subject: [PATCH 09/16] web: authenticate api v1 with bearer tokens --- web/app/controllers/api/v1/base_controller.rb | 45 +++++++++ .../controllers/api/v1/tokens_controller.rb | 16 +++ web/app/models/api/token.rb | 8 ++ web/config/routes.rb | 6 ++ web/test/integration/api_auth_test.rb | 97 +++++++++++++++++++ web/test/integration/who_gets_in_test.rb | 34 ++++++- 6 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 web/app/controllers/api/v1/base_controller.rb create mode 100644 web/app/controllers/api/v1/tokens_controller.rb create mode 100644 web/test/integration/api_auth_test.rb 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..a648fefe --- /dev/null +++ b/web/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,45 @@ +module Api + module V1 + class BaseController < ActionController::API + BEARER = /\ABearer (.+)\z/i + + before_action :require_a_token + + private + + attr_reader :current_token + + 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? + + @current_token = token + token.used! + end + + def presented + request.authorization.to_s[BEARER, 1] + end + + SAID = { + "api_off" => "the mnemosyne api is turned off", + "invalid_token" => "no live token matches that key", + "revoked_token" => "that token was revoked and will not come back", + "bad_channel_id" => "a channel id looks like C0123456789", + "bad_user_id" => "a user id looks like U0123456789", + "channel_not_found" => "no public channel with that id", + "user_not_found" => "no member with that id" + }.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/tokens_controller.rb b/web/app/controllers/api/v1/tokens_controller.rb new file mode 100644 index 00000000..88379930 --- /dev/null +++ b/web/app/controllers/api/v1/tokens_controller.rb @@ -0,0 +1,16 @@ +module Api + module V1 + class TokensController < BaseController + def show + render json: { + name: current_token.name, + prefix: current_token.prefix, + owner_user_id: current_token.owner_user_id, + rate_per_minute: current_token.rate, + created_at: current_token.created_at.utc.iso8601, + last_used_at: current_token.last_used_at&.utc&.iso8601 + } + end + end + end +end diff --git a/web/app/models/api/token.rb b/web/app/models/api/token.rb index 80b6f891..bfc4d786 100644 --- a/web/app/models/api/token.rb +++ b/web/app/models/api/token.rb @@ -39,8 +39,16 @@ def self.mint!(owner_user_id, name) [row, key] end + TOUCH_EVERY = 1.minute + def revoked? = revoked_at.present? + 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 diff --git a/web/config/routes.rb b/web/config/routes.rb index a5d12860..f7d5b118 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -10,6 +10,12 @@ get "dev/be/:user_id", to: "dev_sessions#create", as: :dev_be end + namespace :api do + namespace :v1 do + resource :token, only: [:show], controller: "tokens" + end + end + namespace :you do get "api", to: "api#show", as: :api resource :consent, only: [:update], controller: "consents" diff --git a/web/test/integration/api_auth_test.rb b/web/test/integration/api_auth_test.rb new file mode 100644 index 00000000..5277b360 --- /dev/null +++ b/web/test/integration/api_auth_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class ApiAuthTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox") + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def ask(key = @secret, path: api_v1_token_path) + get path, headers: key ? { "Authorization" => "Bearer #{key}" } : {} + end + + def body + JSON.parse(response.body) + end + + test "a live key is told who it is, and never what it is" do + ask + + assert_response :success + assert_equal ["Toolbox", "UOWNER1", 100], body.values_at("name", "owner_user_id", + "rate_per_minute") + assert_equal @token.prefix, body["prefix"] + assert_no_match(/#{@secret}/, response.body, "the key itself must never come back") + end + + test "no header at all is an invalid token, not a crash" do + ask(nil) + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "a key nobody minted is refused" do + ask("nemo_live_neverissuedatall12") + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "a header that is not a bearer is refused" do + get api_v1_token_path, headers: { "Authorization" => "Basic #{@secret}" } + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "a revoked key says so, so a caller stops retrying" do + @token.revoke!(by: "UOWNER1") + ask + + assert_response :unauthorized + assert_equal "revoked_token", body["error"] + end + + test "the whole api is shut while the flag is off, even with a good key" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + ask + + assert_response :service_unavailable + assert_equal "api_off", body["error"] + end + + test "using a key stamps it, and does not stamp it again on every call" do + assert_nil @token.last_used_at + + ask + first = @token.reload.last_used_at + assert_not_nil first + + ask + assert_equal first, @token.reload.last_used_at, "one write a minute, not one a request" + end + + test "a signed in browser session opens nothing on the api" do + staff = Staff.create!(user_id: "UBOSS2", community_manager: true) + sign_in_as(staff) + + get api_v1_token_path + + assert_response :unauthorized + assert_equal "invalid_token", body["error"] + end + + test "every refusal answers in json, never a redirect or a page" do + Fd::Flag.set!(:public_api, false, by: "UBOSS") + ask(nil) + + assert_equal "application/json", response.media_type + assert body.key?("message"), "a caller is told what to do about it" + end +end diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb index 14bdd4ea..9fa367b2 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -10,6 +10,8 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest MEMBER = %w[you/api you/consents you/tokens].freeze + BEARER = %w[api/v1/tokens].freeze + setup do Rails.application.eager_load! @me = Staff.create!(user_id: "UNOROLE", community_manager: false) @@ -31,8 +33,14 @@ def member_guarded?(name) filters_of(name).include?(:require_a_member) end + def token_guarded?(name) + filters_of(name).include?(:require_a_token) + end + def guarded_controllers - (self.class.controllers - OPEN).select { |name| guarded?(name) || member_guarded?(name) } + (self.class.controllers - OPEN).select do |name| + guarded?(name) || member_guarded?(name) || token_guarded?(name) + end end test "the only routes open to the world are signing in and the health check" do @@ -40,8 +48,8 @@ def guarded_controllers "a route opened up or closed, so this test needs updating" end - test "every controller behind the login demands a role, bar the member area" do - (self.class.controllers - OPEN - MEMBER).each do |name| + test "every controller behind the login demands a role, bar the member area and the api" do + (self.class.controllers - OPEN - MEMBER - BEARER).each do |name| assert guarded?(name), "#{name} lets anybody through" assert_not member_guarded?(name), "#{name} settles for a session where a role is needed" end @@ -54,10 +62,28 @@ def guarded_controllers end assert_equal MEMBER.sort, - (self.class.controllers - OPEN).reject { |name| guarded?(name) }.sort, + (self.class.controllers - OPEN - BEARER).reject { |name| guarded?(name) }.sort, "a controller dropped its role check, so this test needs updating" end + test "the api demands a token, never a session or a role, and is only what is listed" do + BEARER.each do |name| + assert token_guarded?(name), "#{name} lets anybody through" + assert_not guarded?(name), "#{name} demands a role, so it is not reachable by a token" + assert_not member_guarded?(name), "#{name} demands a session, which an api caller has not got" + end + + assert_equal BEARER.sort, + (self.class.controllers - OPEN).select { |name| token_guarded?(name) }.sort, + "a controller started taking bearer tokens, so this test needs updating" + end + + test "the api carries no session at all, so a browser cannot ride in on cookies" do + assert_not Api::V1::BaseController.ancestors.include?(ActionController::Cookies), + "an api controller that reads cookies can be driven by a logged in browser" + assert_not Api::V1::BaseController.ancestors.include?(ApplicationController) + end + test "the turbo routes are open because they carry nothing but a go back" do TURBO.each do |path| get path From b7c03bb1be14ad180bf253c745ebabc3b3221b08 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 01:41:24 +0300 Subject: [PATCH 10/16] web: answer channel manager checks --- .../api/v1/channel_managers_controller.rb | 90 +++++++++ web/app/models/api/channel_manager.rb | 10 + web/app/models/api/channel_sweep.rb | 15 ++ web/app/models/api/request_log.rb | 19 ++ web/app/services/channel_managers.rb | 87 ++++++++ web/config/routes.rb | 4 + .../integration/api_channel_managers_test.rb | 169 ++++++++++++++++ web/test/integration/who_gets_in_test.rb | 2 +- web/test/models/channel_managers_test.rb | 185 ++++++++++++++++++ 9 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 web/app/controllers/api/v1/channel_managers_controller.rb create mode 100644 web/app/models/api/channel_manager.rb create mode 100644 web/app/models/api/channel_sweep.rb create mode 100644 web/app/models/api/request_log.rb create mode 100644 web/app/services/channel_managers.rb create mode 100644 web/test/integration/api_channel_managers_test.rb create mode 100644 web/test/models/channel_managers_test.rb 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..611ef8ba --- /dev/null +++ b/web/app/controllers/api/v1/channel_managers_controller.rb @@ -0,0 +1,90 @@ +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) + return refuse(:not_found, "channel_not_found") unless known?(channel) + + found = answers(channel, [user]) + render json: { channel_id: channel }.merge(found[:results].first).merge(found[:about]) + end + + def check + channel = params[:channel_id].to_s + users = Array(params[:user_ids]).map(&:to_s) + most = ::Api::Setting.value("batch_max") + + return refuse(:unprocessable_content, "bad_channel_id") unless CHANNEL.match?(channel) + return refuse(:unprocessable_content, "too_many_subjects", most: most) if users.size > most + return refuse(:unprocessable_content, "bad_user_id") unless users.all? { MEMBER.match?(_1) } + return refuse(:not_found, "channel_not_found") unless known?(channel) + + found = answers(channel, users.uniq) + render json: { channel_id: channel }.merge(found[:about]).merge(results: found[:results]) + end + + private + + def known?(channel_id) + Analytics::DimChannel.exists?(channel_id: channel_id) + end + + def answers(channel, users) + granted = ::Api::Consent.where(user_id: users, capability: CAPABILITY, + state: ::Api::Consent::GRANTED).pluck(:user_id).to_set + held = held_by(channel, granted) + swept = granted.any? ? ChannelManagers.swept_at(channel) : nil + + outcomes = [] + results = users.map do |user| + row = one(user, granted, held) + outcomes << [user, outcome_of(row)] + row + end + + ::Api::RequestLog.log!(current_token.id, channel, outcomes) + { results: results, about: about(granted, swept) } + end + + def held_by(channel, granted) + return {} if granted.empty? + + ChannelManagers.freshen(channel) + ::Api::ChannelManager.where(channel_id: channel, user_id: granted.to_a) + .pluck(:user_id, :assigned_at).to_h + end + + def one(user, granted, held) + unless granted.include?(user) + return { user_id: user, consent: "withheld", is_manager: nil, + opt_in_url: "#{request.base_url}/you/api" } + end + + row = { user_id: user, consent: "granted", is_manager: held.key?(user) } + row[:since] = held[user]&.utc&.iso8601 if held.key?(user) + row + end + + def outcome_of(row) + return ::Api::RequestLog::WITHHELD if row[:consent] == "withheld" + + row[:is_manager] ? ::Api::RequestLog::MANAGER : ::Api::RequestLog::NOT_MANAGER + end + + def about(granted, swept) + return {} if granted.empty? + + { synced_at: swept&.utc&.iso8601, + stale: swept.nil? || swept < ChannelManagers::TTL.ago } + end + 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/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/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/config/routes.rb b/web/config/routes.rb index f7d5b118..12c20984 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -13,6 +13,10 @@ namespace :api do namespace :v1 do resource :token, only: [:show], controller: "tokens" + post "channels/:channel_id/managers/check", to: "channel_managers#check", + as: :channel_managers_check + get "channels/:channel_id/managers/:user_id", to: "channel_managers#show", + as: :channel_manager end end diff --git a/web/test/integration/api_channel_managers_test.rb b/web/test/integration/api_channel_managers_test.rb new file mode 100644 index 00000000..0f6fb750 --- /dev/null +++ b/web/test/integration/api_channel_managers_test.rb @@ -0,0 +1,169 @@ +require "test_helper" + +class ApiChannelManagersTest < ActionDispatch::IntegrationTest + MANAGER = "U0BGRUHPTTR".freeze + BYSTANDER = "U04KX9TQ2AA".freeze + + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox") + @channel = Analytics::DimChannel.first.channel_id + Api::ChannelManager.delete_all + Api::ChannelSweep.delete_all + Api::RequestLog.delete_all + Api::Consent.delete_all + manages(MANAGER) + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def manages(user_id, on: @channel) + Api::ChannelManager.create!(channel_id: on, user_id: user_id, assigned_at: 1.year.ago) + Api::ChannelSweep.stamp!(on, 1) + end + + def opted_in(user_id) + Api::Consent.set!(user_id, "channel_manager", true, via: "dashboard") + end + + def head(key = @secret) + { "Authorization" => "Bearer #{key}" } + end + + def ask(user_id, on: @channel) + get api_v1_channel_manager_path(channel_id: on, user_id: user_id), headers: head + end + + def batch(user_ids, on: @channel) + post api_v1_channel_managers_check_path(channel_id: on), + params: { user_ids: user_ids }.to_json, + headers: head.merge("Content-Type" => "application/json") + end + + def body = JSON.parse(response.body) + + test "an opted in manager is answered yes, with when and how fresh" do + opted_in(MANAGER) + ask(MANAGER) + + assert_response :success + assert_equal [@channel, MANAGER, "granted", true], body.values_at("channel_id", "user_id", + "consent", "is_manager") + assert_not body["stale"] + assert body["synced_at"].present? + assert body["since"].present? + end + + test "an opted in bystander is answered no, and carries no since" do + opted_in(BYSTANDER) + ask(BYSTANDER) + + assert_response :success + assert_equal ["granted", false], body.values_at("consent", "is_manager") + assert_not body.key?("since") + end + + test "somebody who never opted in is withheld, and told nothing else" do + ask(MANAGER) + + assert_response :success + assert_equal "withheld", body["consent"] + assert_nil body["is_manager"] + assert_match(%r{/you/api\z}, body["opt_in_url"]) + end + + test "withheld looks the same whether or not they manage it" do + ask(MANAGER) + managing = body + + ask(BYSTANDER) + + assert_equal managing.except("user_id"), body.except("user_id"), + "the shape must not leak the answer it is withholding" + end + + test "a withheld ask never carries freshness, because nothing was read" do + ask(MANAGER) + + assert_not body.key?("synced_at") + assert_not body.key?("stale") + end + + test "a channel we do not hold is not found, before consent is even looked at" do + opted_in(MANAGER) + ask(MANAGER, on: "CDOESNOTEXIST") + + assert_response :not_found + assert_equal "channel_not_found", body["error"] + assert_empty Api::RequestLog.all, "a 404 is not an ask about anybody" + end + + test "a malformed id is refused before anything is read" do + ask("nope") + assert_response :unprocessable_content + assert_equal "bad_user_id", body["error"] + + ask(MANAGER, on: "nope") + assert_response :unprocessable_content + assert_equal "bad_channel_id", body["error"] + end + + test "every ask is written down, with what it was told" do + opted_in(MANAGER) + ask(MANAGER) + ask(BYSTANDER) + + logged = Api::RequestLog.order(:id).pluck(:subject_user_id, :outcome, :channel_id) + assert_equal [[MANAGER, "manager", @channel], [BYSTANDER, "withheld", @channel]], logged + assert_equal [@token.id], Api::RequestLog.distinct.pluck(:token_id) + end + + test "a batch answers each subject and costs one row each" do + opted_in(MANAGER) + batch([MANAGER, BYSTANDER]) + + assert_response :success + assert_equal @channel, body["channel_id"] + assert_equal [MANAGER, BYSTANDER], body["results"].map { _1["user_id"] } + assert_equal [true, nil], body["results"].map { _1["is_manager"] } + assert_equal 2, Api::RequestLog.count + end + + test "a batch over the cap is refused and says what the cap is" do + batch(Array.new(101) { |i| format("U%010d", i) }) + + assert_response :unprocessable_content + assert_equal "too_many_subjects", body["error"] + assert_equal 100, body["most"] + assert_empty Api::RequestLog.all + end + + test "one bad id spoils the batch rather than being quietly dropped" do + batch([MANAGER, "nope"]) + + assert_response :unprocessable_content + assert_equal "bad_user_id", body["error"] + end + + test "a batch nobody consented to reads nothing about the channel" do + asked = [] + was = ChannelManagers.method(:freshen) + ChannelManagers.define_singleton_method(:freshen) { |id| asked << id } + batch([MANAGER, BYSTANDER]) + + assert_empty asked, "a wholly withheld batch must not touch slack" + assert_equal 2, Api::RequestLog.where(outcome: "withheld").count + ensure + ChannelManagers.define_singleton_method(:freshen, was) + end + + test "the api still needs a token for a check" do + get api_v1_channel_manager_path(channel_id: @channel, user_id: MANAGER) + + assert_response :unauthorized + assert_empty Api::RequestLog.all + end +end diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb index 9fa367b2..ac08dff8 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -10,7 +10,7 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest MEMBER = %w[you/api you/consents you/tokens].freeze - BEARER = %w[api/v1/tokens].freeze + BEARER = %w[api/v1/tokens api/v1/channel_managers].freeze setup do Rails.application.eager_load! diff --git a/web/test/models/channel_managers_test.rb b/web/test/models/channel_managers_test.rb new file mode 100644 index 00000000..92f22719 --- /dev/null +++ b/web/test/models/channel_managers_test.rb @@ -0,0 +1,185 @@ +require "test_helper" + +class ChannelManagersTest < ActiveSupport::TestCase + CHANNEL = "C0BGRUMA85D".freeze + + setup do + @asked = [] + @was_role = ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] + ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = "Rl0A" + Api::ChannelManager.delete_all + Api::ChannelSweep.delete_all + Rails.cache.clear + end + + teardown do + ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = @was_role + end + + def swapping(replier) + was = Slack::ProxyClient.method(:call) + Slack::ProxyClient.define_singleton_method(:call, &replier) + yield + ensure + Slack::ProxyClient.define_singleton_method(:call, was) + end + + def page(*user_ids, on: CHANNEL, cursor: nil) + { + "ok" => true, + "role_assignments" => user_ids.map do |user_id| + { "role_id" => "Rl0A", "entity_id" => on, "user_id" => user_id, + "date_create" => 1_700_000_000 } + end, + "response_metadata" => { "next_cursor" => cursor.to_s } + } + end + + def answering(*pages, &block) + replies = pages.dup + asked = @asked + fallback = page + swapping(lambda { |method, params, **options| + asked << [method, params, options] + replies.shift || fallback + }, &block) + end + + def raising(error, &block) + asked = @asked + swapping(lambda { |*_args, **_options| + asked << :tried + raise error + }, &block) + end + + test "a channel nobody has asked about is fetched once and remembered" do + found = answering(page("U1", "U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1 U2], found + assert_equal 1, @asked.size + assert_equal %w[U1 U2], Api::ChannelManager.user_ids_in(CHANNEL) + assert_equal 2, Api::ChannelSweep.find(CHANNEL).managers + end + + test "it asks slack for one channel and one role, never the whole workspace" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + + method, params, options = @asked.sole + assert_equal "admin.roles.listAssignments", method + assert_equal "Rl0A", params[:role_ids] + assert_equal CHANNEL, params[:entity_ids] + assert_equal "admin", options[:credential] + end + + test "a fresh channel is answered without troubling slack at all" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + @asked.clear + + found = answering { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], found + assert_empty @asked, "a warm channel must cost nothing" + end + + test "a channel nobody manages is remembered as empty, not refetched forever" do + answering(page) { ChannelManagers.for(CHANNEL) } + @asked.clear + + assert_empty answering { ChannelManagers.for(CHANNEL) } + assert_empty @asked, "no rows is an answer, not a cold cache" + assert_equal 0, Api::ChannelSweep.find(CHANNEL).managers + end + + test "once the stamp goes stale it is fetched again" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + @asked.clear + + found = answering(page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U2], found + assert_equal 1, @asked.size + end + + test "a refresh replaces the channel, so somebody who stepped down stops managing it" do + answering(page("U1", "U2")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + + answering(page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U2], Api::ChannelManager.user_ids_in(CHANNEL) + assert_not ChannelManagers.manages?(CHANNEL, "U1") + end + + test "only one refresh of a stale channel goes out at a time" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + @asked.clear + + answering(page("U2")) { ChannelManagers.for(CHANNEL) } + answering(page("U3")) { ChannelManagers.for(CHANNEL) } + + assert_equal 1, @asked.size, "the second caller serves what the first stored" + assert_equal %w[U2], Api::ChannelManager.user_ids_in(CHANNEL) + end + + test "a cold channel is always fetched, lock or no lock" do + ChannelManagers.claim(CHANNEL) + + found = answering(page("U1")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], found, "answering false about a channel we never read is worse than waiting" + end + + test "slack falling over serves what we already hold and leaves the stamp alone" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + stamped = Api::ChannelSweep.find(CHANNEL).synced_at + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + + found = raising(Slack::ProxyClient::Unavailable) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], found + assert_not_equal stamped, Api::ChannelSweep.find(CHANNEL).synced_at + assert_equal %w[U1], Api::ChannelManager.user_ids_in(CHANNEL) + end + + test "a refusal from slack does not wipe the channel" do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + + answering({ "ok" => false, "error" => "missing_scope" }) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], Api::ChannelManager.user_ids_in(CHANNEL) + end + + test "an assignment on some other channel is dropped, whatever slack sends" do + answering(page("U1", on: "COTHER")) { ChannelManagers.for(CHANNEL) } + + assert_empty Api::ChannelManager.user_ids_in(CHANNEL) + assert_equal 0, Api::ChannelSweep.find(CHANNEL).managers + end + + test "it follows the cursor to the end" do + found = answering(page("U1", cursor: "more"), page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1 U2], found + assert_equal 2, @asked.size + assert_nil @asked.first[1][:cursor], "the first call sends no cursor at all" + assert_equal "more", @asked.last[1][:cursor] + end + + test "with no role id pinned it asks nothing and claims nothing" do + ENV["SLACK_CHANNEL_MANAGER_ROLE_ID"] = nil + found = answering(page("U1")) { ChannelManagers.for(CHANNEL) } + + assert_empty found + assert_empty @asked + assert_nil Api::ChannelSweep.find_by(channel_id: CHANNEL) + end + + test "a blank channel id is answered without a query" do + assert_empty answering { ChannelManagers.for("") } + assert_empty @asked + end +end From 34e8b8e8d188d6ffde9c7dc7a382d27b9fc40dac Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 01:46:35 +0300 Subject: [PATCH 11/16] web: rate limit api tokens --- db/migrations/0057_api_rate_default.sql | 4 + web/app/controllers/api/v1/base_controller.rb | 31 ++++- web/app/models/api/setting.rb | 2 +- web/test/integration/api_auth_test.rb | 2 +- web/test/integration/api_rate_limit_test.rb | 110 ++++++++++++++++++ web/test/models/channel_managers_test.rb | 37 ++++-- web/test/test_helper.rb | 8 ++ 7 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 db/migrations/0057_api_rate_default.sql create mode 100644 web/test/integration/api_rate_limit_test.rb diff --git a/db/migrations/0057_api_rate_default.sql b/db/migrations/0057_api_rate_default.sql new file mode 100644 index 00000000..8a1679af --- /dev/null +++ b/db/migrations/0057_api_rate_default.sql @@ -0,0 +1,4 @@ +UPDATE api.setting +SET value = 20 +WHERE key = 'rate_per_minute' + AND value = 100; diff --git a/web/app/controllers/api/v1/base_controller.rb b/web/app/controllers/api/v1/base_controller.rb index a648fefe..692c544f 100644 --- a/web/app/controllers/api/v1/base_controller.rb +++ b/web/app/controllers/api/v1/base_controller.rb @@ -3,12 +3,40 @@ module V1 class BaseController < ActionController::API BEARER = /\ABearer (.+)\z/i + WINDOW = 60 + KEPT_FOR = 2.minutes + before_action :require_a_token + before_action :within_budget private attr_reader :current_token + def within_budget + limit = current_token.rate + used = Rails.cache.increment(bucket, 1, expires_in: KEPT_FOR).to_i + budget!(limit, used) + return if used <= limit + + response.headers["Retry-After"] = left.to_s + refuse(:too_many_requests, "rate_limited", retry_after: left) + end + + def bucket + "api/rate/#{current_token.id}/#{Time.current.to_i / WINDOW}" + end + + def left + WINDOW - (Time.current.to_i % WINDOW) + end + + def budget!(limit, used) + response.headers["RateLimit-Limit"] = limit.to_s + response.headers["RateLimit-Remaining"] = [limit - used, 0].max.to_s + response.headers["RateLimit-Reset"] = left.to_s + end + def require_a_token return refuse(:service_unavailable, "api_off") if Fd::Flag.off?(:public_api) @@ -34,7 +62,8 @@ def presented "bad_channel_id" => "a channel id looks like C0123456789", "bad_user_id" => "a user id looks like U0123456789", "channel_not_found" => "no public channel with that id", - "user_not_found" => "no member with that id" + "too_many_subjects" => "ask about fewer people in one call", + "rate_limited" => "too many calls this minute, wait and try again" }.freeze def refuse(status, error, **extra) diff --git a/web/app/models/api/setting.rb b/web/app/models/api/setting.rb index d35808a4..5ab24a1e 100644 --- a/web/app/models/api/setting.rb +++ b/web/app/models/api/setting.rb @@ -4,7 +4,7 @@ class Setting < ApplicationRecord self.primary_key = "key" DEFAULTS = { - "rate_per_minute" => 100, + "rate_per_minute" => 20, "batch_max" => 100, "tokens_per_owner" => 3 }.freeze diff --git a/web/test/integration/api_auth_test.rb b/web/test/integration/api_auth_test.rb index 5277b360..285fb03e 100644 --- a/web/test/integration/api_auth_test.rb +++ b/web/test/integration/api_auth_test.rb @@ -23,7 +23,7 @@ def body ask assert_response :success - assert_equal ["Toolbox", "UOWNER1", 100], body.values_at("name", "owner_user_id", + assert_equal ["Toolbox", "UOWNER1", 20], body.values_at("name", "owner_user_id", "rate_per_minute") assert_equal @token.prefix, body["prefix"] assert_no_match(/#{@secret}/, response.body, "the key itself must never come back") diff --git a/web/test/integration/api_rate_limit_test.rb b/web/test/integration/api_rate_limit_test.rb new file mode 100644 index 00000000..ca43efd1 --- /dev/null +++ b/web/test/integration/api_rate_limit_test.rb @@ -0,0 +1,110 @@ +require "test_helper" + +class ApiRateLimitTest < ActionDispatch::IntegrationTest + setup do + Fd::Flag.set!(:public_api, true, by: "UBOSS") + @token, @secret = Api::Token.mint!("UOWNER1", "Toolbox") + end + + teardown do + Fd::Flag.delete_all + Current.forget_flags + end + + def ask(key = @secret) + get api_v1_token_path, headers: { "Authorization" => "Bearer #{key}" } + end + + def budget + response.headers.slice("RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset") + end + + def body = JSON.parse(response.body) + + test "the default budget is twenty a minute" do + assert_equal 20, Api::Setting.value("rate_per_minute") + assert_equal 20, @token.rate + end + + test "every answer says how much budget is left" do + with_a_real_cache do + ask + + assert_response :success + assert_equal "20", budget["RateLimit-Limit"] + assert_equal "19", budget["RateLimit-Remaining"] + assert budget["RateLimit-Reset"].to_i.between?(1, 60) + end + end + + test "the remaining budget counts down, and stops at nought" do + with_a_real_cache do + 3.times { ask } + assert_equal "17", budget["RateLimit-Remaining"] + + 20.times { ask } + assert_equal "0", budget["RateLimit-Remaining"], "it never goes negative" + end + end + + test "past the budget it refuses, and says when to come back" do + with_a_real_cache do + 20.times { ask } + assert_response :success + + ask + + assert_response :too_many_requests + assert_equal "rate_limited", body["error"] + assert body["retry_after"].to_i.between?(1, 60) + assert_equal response.headers["Retry-After"], body["retry_after"].to_s + end + end + + test "one token running hot does not spend another token's budget" do + with_a_real_cache do + other, spare = Api::Token.mint!("UOWNER2", "Arcade") + 21.times { ask } + assert_response :too_many_requests + + ask(spare) + + assert_response :success + assert_equal "19", budget["RateLimit-Remaining"] + assert_equal other.rate, budget["RateLimit-Limit"].to_i + end + end + + test "a token with its own limit is held to that, not the shared one" do + with_a_real_cache do + @token.update!(rate_limit: 2) + + 2.times { ask } + assert_response :success + assert_equal "2", budget["RateLimit-Limit"] + + ask + assert_response :too_many_requests + end + end + + test "a refusal before the token is known carries no budget at all" do + with_a_real_cache do + ask("nemo_live_neverissuedatall12") + + assert_response :unauthorized + assert_empty budget, "there is no budget to report without a token" + end + end + + test "the budget is spent on checks too, not only on the token route" do + with_a_real_cache do + channel = Analytics::DimChannel.first.channel_id + get api_v1_channel_manager_path(channel_id: channel, user_id: "U0BGRUHPTTR"), + headers: { "Authorization" => "Bearer #{@secret}" } + + assert_response :success + assert_equal "19", budget["RateLimit-Remaining"] + end + end +end diff --git a/web/test/models/channel_managers_test.rb b/web/test/models/channel_managers_test.rb index 92f22719..8ca18ab9 100644 --- a/web/test/models/channel_managers_test.rb +++ b/web/test/models/channel_managers_test.rb @@ -112,7 +112,32 @@ def raising(error, &block) assert_not ChannelManagers.manages?(CHANNEL, "U1") end - test "only one refresh of a stale channel goes out at a time" do + test "a stale channel already being refreshed is not fetched a second time" do + with_a_real_cache do + answering(page("U1")) { ChannelManagers.for(CHANNEL) } + Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) + @asked.clear + ChannelManagers.claim(CHANNEL) + + found = answering(page("U2")) { ChannelManagers.for(CHANNEL) } + + assert_empty @asked, "somebody else holds the lock, so this caller serves what we have" + assert_equal %w[U1], found + end + end + + test "a cold channel is always fetched, lock or no lock" do + with_a_real_cache do + ChannelManagers.claim(CHANNEL) + + found = answering(page("U1")) { ChannelManagers.for(CHANNEL) } + + assert_equal %w[U1], found, + "answering false about a channel we never read is worse than waiting" + end + end + + test "a refreshed channel is fresh again, so the next caller asks nothing" do answering(page("U1")) { ChannelManagers.for(CHANNEL) } Api::ChannelSweep.find(CHANNEL).update!(synced_at: 2.hours.ago) @asked.clear @@ -120,18 +145,10 @@ def raising(error, &block) answering(page("U2")) { ChannelManagers.for(CHANNEL) } answering(page("U3")) { ChannelManagers.for(CHANNEL) } - assert_equal 1, @asked.size, "the second caller serves what the first stored" + assert_equal 1, @asked.size assert_equal %w[U2], Api::ChannelManager.user_ids_in(CHANNEL) end - test "a cold channel is always fetched, lock or no lock" do - ChannelManagers.claim(CHANNEL) - - found = answering(page("U1")) { ChannelManagers.for(CHANNEL) } - - assert_equal %w[U1], found, "answering false about a channel we never read is worse than waiting" - end - test "slack falling over serves what we already hold and leaves the stamp alone" do answering(page("U1")) { ChannelManagers.for(CHANNEL) } stamped = Api::ChannelSweep.find(CHANNEL).synced_at diff --git a/web/test/test_helper.rb b/web/test/test_helper.rb index 8ce9ff09..568892ad 100644 --- a/web/test/test_helper.rb +++ b/web/test/test_helper.rb @@ -26,6 +26,14 @@ def make_case(subject: "USUB", assign: nil, **attrs) kase end + def with_a_real_cache + was = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + yield + ensure + Rails.cache = was + end + def sign_in_as(staff) OmniAuth.config.test_mode = true OmniAuth.config.mock_auth[:hackclub] = OmniAuth::AuthHash.new( From 7c8b8c7be130a97f8bea8d54f2ae8d56f923f57e Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 01:52:49 +0300 Subject: [PATCH 12/16] web(fd): add the api settings tab --- web/app/assets/tailwind/application.css | 11 ++ .../controllers/fd/api_settings_controller.rb | 72 +++++++++++ web/app/controllers/fd/settings_controller.rb | 18 ++- web/app/helpers/fd_helper.rb | 42 ++++++ .../views/fd/settings/_api_modals.html.erb | 65 ++++++++++ web/app/views/fd/settings/show.html.erb | 121 ++++++++++++++++++ web/config/routes.rb | 3 + web/test/integration/fd_access_test.rb | 2 +- web/test/integration/fd_api_settings_test.rb | 97 ++++++++++++++ 9 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 web/app/controllers/fd/api_settings_controller.rb create mode 100644 web/app/views/fd/settings/_api_modals.html.erb create mode 100644 web/test/integration/fd_api_settings_test.rb diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index 29b3618b..0e03fa6e 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -2827,6 +2827,17 @@ 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; + } + .secret-warn { margin: 0; font-size: var(--t-small); diff --git a/web/app/controllers/fd/api_settings_controller.rb b/web/app/controllers/fd/api_settings_controller.rb new file mode 100644 index 00000000..8f2451ed --- /dev/null +++ b/web/app/controllers/fd/api_settings_controller.rb @@ -0,0 +1,72 @@ +module Fd + class ApiSettingsController < BaseController + skip_before_action :needs_the_engine + permit "access.grant" + + MOST = 100_000 + + def update + key = params[:key].to_s + return refuse("#{key} is not a setting") unless ::Api::Setting::DEFAULTS.key?(key) + + value = params[:value].to_i + return refuse("a setting has to be a number above nought") unless value.positive? + return refuse("#{value} is more than anybody needs") if value > MOST + + was = ::Api::Setting.value(key) + writing do + ::Api::Setting.set!(key, value, by: current_staff.user_id) + ::Api::Event.record!("setting_changed", actor: current_staff.user_id, + subject: key.tr("_", " "), detail: "#{was} to #{value}") + end + + back_to "#{key.tr('_', ' ')} is now #{value}" + end + + def rate + token = ::Api::Token.find_by(id: params[:id]) + return refuse("no such token") if token.nil? + + value = params[:value].presence&.to_i + return refuse("a rate has to be a number above nought") if value && !value.positive? + + was = token.rate + writing do + token.update!(rate_limit: value) + ::Api::Event.record!("token_rate_set", actor: current_staff.user_id, + subject: token.shown, detail: said_rate(token, was, value)) + end + + back_to "#{token.name} is now #{token.rate} a minute" + end + + def destroy + token = ::Api::Token.live.find_by(id: params[:id]) + return refuse("no live token with that id") if token.nil? + + writing do + token.revoke!(by: current_staff.user_id) + ::Api::Event.record!("token_revoked", actor: current_staff.user_id, + subject: token.shown, detail: "#{token.name}, owned by #{token.owner_user_id}") + end + + back_to "Revoked #{token.name}" + end + + private + + def said_rate(token, was, value) + return "#{token.name}, back to the shared #{token.rate}" if value.nil? + + "#{token.name}, #{was} to #{value}" + end + + def back_to(said) + redirect_to fd_settings_path(tab: "api"), notice: said + end + + def refuse(said) + redirect_to fd_settings_path(tab: "api"), alert: said + end + end +end diff --git a/web/app/controllers/fd/settings_controller.rb b/web/app/controllers/fd/settings_controller.rb index fbb3feeb..bfa00310 100644 --- a/web/app/controllers/fd/settings_controller.rb +++ b/web/app/controllers/fd/settings_controller.rb @@ -5,7 +5,7 @@ class SettingsController < BaseController TABS = { "access" => "Access", "roles" => "Roles", "sections" => "Sections", "usage" => "Usage", "history" => "Grant history", - "activity" => "Activity", "you" => "You" }.freeze + "activity" => "Activity", "you" => "You", "api" => "API" }.freeze MINE = %w[you activity].freeze WINDOW = 30.days DORMANT_AFTER = 30.days @@ -32,6 +32,7 @@ def show @person = chosen grant_facts if @person @used = used_lately if @tab == "roles" + api_facts if @tab == "api" usage_facts if @tab == "usage" deed_facts if @person && @tab == "usage" @counts = tab_counts @@ -84,7 +85,19 @@ def mine_lately def tab_counts { "access" => @grants.size, "roles" => Permission.keys.size, - "history" => AccessGrant.count } + "history" => AccessGrant.count, "api" => ::Api::Token.live.count } + end + + def api_facts + @tokens = ::Api::Token.order(revoked_at: :asc, created_at: :desc).to_a + @asks = ::Api::RequestLog.where(at: WINDOW.ago..).group(:token_id).count + @opted_in = ::Api::Consent.where(state: ::Api::Consent::GRANTED).count + @checks = ::Api::RequestLog.where(at: WINDOW.ago..).count + @withheld = ::Api::RequestLog.where(at: WINDOW.ago.., outcome: "withheld").count + @channels = ::Api::RequestLog.where(at: WINDOW.ago..).distinct.count(:channel_id) + @synced = ::Api::ChannelSweep.maximum(:synced_at) + @dials = ::Api::Setting::DEFAULTS.keys.index_with { |key| ::Api::Setting.value(key) } + @last_dial = ::Api::Setting.order(changed_at: :desc).first end def tally_without_grants @@ -189,6 +202,7 @@ def holders def named holders + @grants.map(&:granted_by) + Array(@top_reader&.first) + + Array(@tokens).map(&:owner_user_id) + Array(@last_dial&.changed_by) + Array(@deeds&.member_ids) + Array(@history).flat_map { |grant| [grant.user_id, grant.granted_by, grant.revoked_by] diff --git a/web/app/helpers/fd_helper.rb b/web/app/helpers/fd_helper.rb index 996442fc..eaee4370 100644 --- a/web/app/helpers/fd_helper.rb +++ b/web/app/helpers/fd_helper.rb @@ -1226,6 +1226,48 @@ def deed_said(deed) [deed.said, ("on #{names[deed.who]}" if deed.who.present?)].compact.join(" ") end + DIAL_LABELS = { + "rate_per_minute" => "Requests a minute, per token", + "batch_max" => "People per batch call", + "tokens_per_owner" => "Live tokens per owner" + }.freeze + + def dial_label(key) + DIAL_LABELS.fetch(key, key.tr("_", " ")) + end + + def api_state_chip + on = Fd::Flag.on?(:public_api) + tag.span(class: "chip #{on ? 'chip-good' : 'chip-off'}") do + tag.span(class: "chip-dot", aria: { hidden: true }) + (on ? "On" : "Off") + end + end + + def withheld_share(withheld, checks) + return "none yet" if checks.zero? + + "#{(withheld * 100.0 / checks).round(1)}% of checks" + end + + def synced_line(at) + return "never" if at.nil? + + "#{at.strftime('%-d %b %H:%M')}, #{Api::ChannelSweep.count} channels" + 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? + + [names[setting.changed_by], setting.changed_at.strftime("%-d %b")].compact.join(", ") + end + def acted_line(at) at ? "acted #{last_case_label(at)}" : "nothing yet" end diff --git a/web/app/views/fd/settings/_api_modals.html.erb b/web/app/views/fd/settings/_api_modals.html.erb new file mode 100644 index 00000000..ced45c26 --- /dev/null +++ b/web/app/views/fd/settings/_api_modals.html.erb @@ -0,0 +1,65 @@ +<% @dials.each do |key, value| %> + <%= render layout: "fd/modal", locals: { id: "dial-#{key}", title: dial_label(key), + sub: "in force now: #{value}" } do %> + <%= form_with url: fd_api_setting_path, method: :patch, html: { autocomplete: "off" } do %> + <%= hidden_field_tag :key, key, id: nil %> + + + <% end %> + <% end %> +<% end %> + +<% @tokens.reject(&:revoked?).each do |token| %> + <%= render layout: "fd/modal", locals: { id: "rate-#{token.id}", + title: "Rate for #{token.name}", sub: "owned by #{names[token.owner_user_id]}" } do %> + <%= form_with url: fd_api_token_rate_path(token), method: :patch, + html: { autocomplete: "off" } do %> + + + <% end %> + <% end %> +<% end %> diff --git a/web/app/views/fd/settings/show.html.erb b/web/app/views/fd/settings/show.html.erb index 5fd08fbb..71a18230 100644 --- a/web/app/views/fd/settings/show.html.erb +++ b/web/app/views/fd/settings/show.html.erb @@ -90,6 +90,124 @@
+ <% elsif @tab == "api" %> +
+
+
+

Opted in

+

<%= number_with_delimiter(@opted_in) %>

+

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

+
+
+

Live tokens

+

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

+

across <%= @tokens.map(&:owner_user_id).uniq.size %> owners

+
+
+

Checks, 30 days

+

<%= number_with_delimiter(@checks) %>

+

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

+
+
+

Withheld

+

<%= number_with_delimiter(@withheld) %>

+

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

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

Nobody holds a token

+
+ <% end %> + <% elsif @tab == "sections" %>
@@ -506,6 +624,9 @@ <% if current_staff.may?("access.grant") %> + <% if @tab == "api" %> + <%= render "fd/settings/api_modals" %> + <% end %> <%= render "fd/settings/give_modal" %> <% if @person %> <%= render "fd/settings/change_modal", grant: @person %> diff --git a/web/config/routes.rb b/web/config/routes.rb index 12c20984..e8a6588a 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -37,6 +37,9 @@ end resource :search, only: [:show], controller: "searches" resource :settings, only: [:show] + patch "api_setting", to: "api_settings#update", as: :api_setting + patch "api_tokens/:id/rate", to: "api_settings#rate", as: :api_token_rate + delete "api_tokens/:id", to: "api_settings#destroy", as: :api_token get "audit", to: "audits#show", as: :audit get "slack_account/callback", to: "slack_accounts#callback", as: :slack_account_callback resource :slack_account, only: [:create, :destroy], controller: "slack_accounts" diff --git a/web/test/integration/fd_access_test.rb b/web/test/integration/fd_access_test.rb index 8f33695a..3457dde0 100644 --- a/web/test/integration/fd_access_test.rb +++ b/web/test/integration/fd_access_test.rb @@ -66,7 +66,7 @@ def self.enforced end.map(&:first).uniq assert_equal %w[fd/settlements fd/supersessions fd/retirements fd/grants - fd/role_permissions fd/flags].sort, + fd/role_permissions fd/flags fd/api_settings].sort, lead_only.sort, "a lead-only route appeared or vanished, so this test needs updating" end diff --git a/web/test/integration/fd_api_settings_test.rb b/web/test/integration/fd_api_settings_test.rb new file mode 100644 index 00000000..c695f282 --- /dev/null +++ b/web/test/integration/fd_api_settings_test.rb @@ -0,0 +1,97 @@ +require "test_helper" + +class FdApiSettingsTest < ActionDispatch::IntegrationTest + setup do + @boss = Staff.create!(user_id: "UBOSS9", community_manager: true) + Fd::AccessGrant.give!("UFIRE9", role: "firefighter", by: @boss.user_id) + @token, = Api::Token.mint!("UOWNER9", "Toolbox") + sign_in_as(@boss) + end + + def dials + Api::Setting::DEFAULTS.keys.index_with { |key| Api::Setting.value(key) } + end + + test "the tab lists every token with its owner and its rate" do + get fd_settings_path(tab: "api") + + assert_response :success + assert_select ".data-table td", text: "Toolbox" + assert_select ".data-table td.mono", text: @token.shown + assert_select ".fbox .row-k", text: "Requests a minute, per token" + end + + test "a manager moves a dial, and it is written down" do + assert_difference -> { Api::Event.count }, 1 do + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 250 } + end + + assert_equal 250, Api::Setting.value("rate_per_minute") + said = Api::Event.last + assert_equal ["setting_changed", @boss.user_id, "20 to 250"], + [said.verb, said.actor_user_id, said.detail] + end + + test "a dial that does not exist, or a silly number, changes nothing" do + was = dials + + patch fd_api_setting_path, params: { key: "wingspan", value: 5 } + assert_match(/not a setting/, flash[:alert]) + + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 0 } + assert_match(/above nought/, flash[:alert]) + + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 999_999_999 } + assert_match(/more than anybody needs/, flash[:alert]) + + assert_equal was, dials + assert_equal 0, Api::Event.count + end + + test "a manager gives one token its own rate, and takes it away again" do + patch fd_api_token_rate_path(@token), params: { value: 600 } + assert_equal 600, @token.reload.rate + + patch fd_api_token_rate_path(@token), params: { value: "" } + assert_nil @token.reload.rate_limit + assert_equal Api::Setting.value("rate_per_minute"), @token.rate + assert_equal 2, Api::Event.where(verb: "token_rate_set").count + end + + test "a manager revokes somebody else's token, and it is named to them" do + delete fd_api_token_path(@token) + + assert_predicate @token.reload, :revoked? + assert_equal @boss.user_id, @token.revoked_by + said = Api::Event.where(verb: "token_revoked").sole + assert_match(/owned by UOWNER9/, said.detail) + end + + test "revoking the same token twice is refused rather than logged twice" do + delete fd_api_token_path(@token) + + assert_no_difference -> { Api::Event.count } do + delete fd_api_token_path(@token) + end + + assert_match(/no live token/, flash[:alert]) + end + + test "a firefighter cannot move a dial or touch a token" do + sign_in_as(Staff.find("UFIRE9")) + + patch fd_api_setting_path, params: { key: "rate_per_minute", value: 999 } + delete fd_api_token_path(@token) + + assert_equal 20, Api::Setting.value("rate_per_minute") + assert_not_predicate @token.reload, :revoked? + assert_equal 0, Api::Event.count + end + + test "a firefighter does not even see the tab" do + sign_in_as(Staff.find("UFIRE9")) + get fd_settings_path(tab: "api") + + assert_select ".views .view", text: /API/, count: 0 + end +end From 458953018e479899b65aeb94e97c87b35f093a75 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 01:59:14 +0300 Subject: [PATCH 13/16] web(fd): roll up api requests into the audit log --- web/app/helpers/fd_helper.rb | 3 ++ web/app/models/fd/deeds.rb | 67 ++++++++++++++++++++++++++++++-- web/test/models/fd/deeds_test.rb | 54 +++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/web/app/helpers/fd_helper.rb b/web/app/helpers/fd_helper.rb index eaee4370..aac467a9 100644 --- a/web/app/helpers/fd_helper.rb +++ b/web/app/helpers/fd_helper.rb @@ -1151,7 +1151,10 @@ def acted_label(at) "decision/superseded" => "Retired", "consent/granted" => "Opted in to", "consent/withheld" => "Opted out of", + "api/checked" => "Checked", + "api/setting_changed" => "Changed the", "api/token_minted" => "Generated a token,", + "api/token_rate_set" => "Set the rate on", "api/token_revoked" => "Revoked a token,", "decision_thread/attached" => "Linked a thread to", "decision_thread/detached" => "Unlinked a thread from", diff --git a/web/app/models/fd/deeds.rb b/web/app/models/fd/deeds.rb index bf855744..f2907983 100644 --- a/web/app/models/fd/deeds.rb +++ b/web/app/models/fd/deeds.rb @@ -13,7 +13,8 @@ class Deeds VIA = { "dashboard" => "from the dashboard", "command" => "from Slack" }.freeze KIND_VIEWS = { - "audit" => "audit", "read" => "read", "consent" => "api", "event" => "api" + "audit" => "audit", "read" => "read", "consent" => "api", "event" => "api", + "request" => "api" }.freeze Row = Struct.new(:at, :event, :kind, :id, :about, :who, :said, :actor, keyword_init: true) @@ -68,7 +69,7 @@ def picked(select = "kind, id, at") end def union - "#{audit_side}#{reads_side}#{consent_side}#{event_side}" + "#{audit_side}#{reads_side}#{consent_side}#{event_side}#{request_side}" end def nothing_asked? @@ -149,6 +150,21 @@ def event_side SQL end + def request_side + return "" unless wanted?("api") + return "" if @only + + mine = @user_id ? "AND t.owner_user_id = :who" : "" + <<~SQL + #{lead_for(audit_side, reads_side, consent_side, event_side)} + SELECT 'request' AS kind, r.token_id AS id, date_trunc('day', r.at) AS at + FROM api.request_log r + JOIN api.token t ON t.id = r.token_id + WHERE r.at >= :since #{mine} + GROUP BY r.token_id, date_trunc('day', r.at) + SQL + end + def lead_for(*sides) sides.all?(&:empty?) ? "" : "UNION ALL" end @@ -182,6 +198,7 @@ def built read_rows = reads_for(chosen.select { |row| row["kind"] == "read" }.map { |row| row["id"] }) consent_rows = consents_for(ids_of(chosen, "consent")) event_rows = events_for(ids_of(chosen, "event")) + request_rows = requests_for(chosen.select { |row| row["kind"] == "request" }) found = entries @actions = Action.where(id: ids(found, "action")).index_by(&:id) @@ -192,19 +209,61 @@ def built .pluck(:id, :title).to_h made = found.map { |row| row_for(row).tap { |one| one.actor = row.actor_user_id } } - (made + read_rows + consent_rows + event_rows).sort_by { |row| -row.at.to_i } + (made + read_rows + consent_rows + event_rows + request_rows) + .sort_by { |row| -row.at.to_i } end def ids_of(chosen, kind) chosen.select { |row| row["kind"] == kind }.map { |row| row["id"] } end + ROLLED = <<~SQL.freeze + SELECT r.token_id, date_trunc('day', r.at) AS day, t.name AS token_name, + t.owner_user_id AS owner, count(*) AS asks, + count(*) FILTER (WHERE r.outcome = 'withheld') AS withheld, + count(DISTINCT r.subject_user_id) AS people, + count(DISTINCT r.channel_id) AS rooms + FROM api.request_log r + JOIN api.token t ON t.id = r.token_id + WHERE r.at >= :since + GROUP BY r.token_id, date_trunc('day', r.at), t.name, t.owner_user_id + SQL + + def requests_for(picked_rows) + return [] if picked_rows.empty? + + wanted = picked_rows.map { |row| [row["id"].to_i, row["at"].to_time.to_i] }.to_set + rolled.filter_map do |row| + next unless wanted.include?([row["token_id"].to_i, row["day"].to_time.to_i]) + + request_row(row) + end + end + + def rolled + AuditEntry.connection.select_all( + AuditEntry.sanitize_sql([ROLLED, { since: @since }]) + ) + end + + def request_row(row) + people = row["people"].to_i + withheld = row["withheld"].to_i + rooms = row["rooms"].to_i + said = ["#{rooms} #{'channel'.pluralize(rooms)}"] + said << "#{withheld} withheld" if withheld.positive? + + Row.new(at: row["day"], event: "api/checked", kind: "capability", + about: "#{people} #{'member'.pluralize(people)}", actor: row["owner"], + said: ([row["token_name"]] + said).join(" · ")) + end + def events_for(ids) return [] if ids.empty? ::Api::Event.where(id: ids).map do |event| Row.new(at: event.at, event: "api/#{event.verb}", kind: "capability", - about: event.detail, actor: event.actor_user_id, said: event.subject) + about: event.subject, actor: event.actor_user_id, said: event.detail) end end diff --git a/web/test/models/fd/deeds_test.rb b/web/test/models/fd/deeds_test.rb index 6bc07e4b..35c3d7e7 100644 --- a/web/test/models/fd/deeds_test.rb +++ b/web/test/models/fd/deeds_test.rb @@ -151,6 +151,60 @@ def all_three "one line each way, whatever order a shared timestamp puts them in" end + def checked(token, on: "C0DESIGN99", subjects: ["USUB"], outcome: "manager", at: 1.hour.ago) + Api::RequestLog.insert_all(subjects.map do |subject| + { token_id: token.id, channel_id: on, subject_user_id: subject, + outcome: outcome, at: at } + end) + end + + test "a day of api traffic reads as one line, not one line a call" do + token, = Api::Token.mint!(WHO, "Toolbox") + checked(token, subjects: %w[U1 U2 U3]) + + row = deeds(view: "api").sole + assert_equal ["api/checked", "3 members", WHO], [row.event, row.about, row.actor] + assert_equal "Toolbox · 1 channel", row.said + end + + test "the roll up counts channels and what was withheld" do + token, = Api::Token.mint!(WHO, "Toolbox") + checked(token, on: "C0ONE", subjects: %w[U1 U2]) + checked(token, on: "C0TWO", subjects: %w[U3], outcome: "withheld") + + assert_equal "Toolbox · 2 channels · 1 withheld", deeds(view: "api").sole.said + end + + test "two days of traffic are two lines, and two tokens are two more" do + mine, = Api::Token.mint!(WHO, "Toolbox") + theirs, = Api::Token.mint!("UOTHER", "Arcade") + checked(mine, at: 1.hour.ago) + checked(mine, at: 2.days.ago) + checked(theirs, at: 1.hour.ago) + + assert_equal 3, Fd::Deeds.new(nil, since: 30.days.ago, view: "api").rows.size + end + + test "a token owner sees their own traffic and not somebody else's" do + mine, = Api::Token.mint!(WHO, "Toolbox") + theirs, = Api::Token.mint!("UOTHER", "Arcade") + checked(mine) + checked(theirs) + + assert_equal ["Toolbox · 1 channel"], deeds(view: "api").map(&:said) + end + + test "the api count covers consent, events and traffic alike" do + token, = Api::Token.mint!(WHO, "Toolbox") + checked(token, subjects: %w[U1 U2]) + Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") + Api::Event.record!("token_minted", actor: WHO, subject: token.shown, detail: "Toolbox") + + counted = Fd::Deeds.new(WHO, since: 30.days.ago).totals + assert_equal 3, counted["api"], "one roll up, one consent line, one event" + assert_equal deeds(view: "api").size, counted["api"] + end + test "consent changes are asked for by nobody looking at one permission" do Api::Consent.set!(WHO, "channel_manager", true, via: "dashboard") audit("case", make_case.id, "opened") From 28a41344d58bd7fccb3b21ceb6a31aedaa0c10a3 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 02:33:13 +0300 Subject: [PATCH 14/16] web(docs): channel managers' api --- db/migrations/0058_api_drop_batch_max.sql | 1 + web/app/assets/tailwind/application.css | 231 ++++++++++++++++++ web/app/controllers/api/v1/base_controller.rb | 23 +- .../api/v1/channel_managers_controller.rb | 72 ++---- web/app/controllers/docs_controller.rb | 5 + web/app/models/api/setting.rb | 1 - web/app/models/docs.rb | 24 ++ web/app/views/docs/_channel_managers.html.erb | 159 ++++++++++++ web/app/views/docs/show.html.erb | 19 ++ web/app/views/layouts/application.html.erb | 17 +- web/config/routes.rb | 4 +- .../integration/api_channel_managers_test.rb | 79 +++--- web/test/integration/api_docs_test.rb | 65 +++++ web/test/integration/who_gets_in_test.rb | 2 +- 14 files changed, 583 insertions(+), 119 deletions(-) create mode 100644 db/migrations/0058_api_drop_batch_max.sql create mode 100644 web/app/controllers/docs_controller.rb create mode 100644 web/app/models/docs.rb create mode 100644 web/app/views/docs/_channel_managers.html.erb create mode 100644 web/app/views/docs/show.html.erb create mode 100644 web/test/integration/api_docs_test.rb diff --git a/db/migrations/0058_api_drop_batch_max.sql b/db/migrations/0058_api_drop_batch_max.sql new file mode 100644 index 00000000..ef51d329 --- /dev/null +++ b/db/migrations/0058_api_drop_batch_max.sql @@ -0,0 +1 @@ +DELETE FROM api.setting WHERE key = 'batch_max'; diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index 0e03fa6e..fa9779e1 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -2827,6 +2827,237 @@ flex: none; } + .docs { + display: grid; + grid-template-columns: 196px minmax(0, 1fr); + gap: var(--s-xl); + align-items: start; + } + + @media (max-width: 900px) { + .docs { grid-template-columns: minmax(0, 1fr); gap: var(--s-md); } + .docs-nav { position: static; } + } + + .docs-nav { + position: sticky; + top: 0; + display: grid; + gap: 1px; + align-content: start; + padding-block: 2px; + } + + .docs-nav-h { + font-size: var(--t-fine); + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--ink-4); + margin: 0 0 5px; + padding: 0 8px; + } + + .docs-nav-h + .docs-nav-h, + .docs-nav a + .docs-nav-h { + margin-top: var(--s-sm); + } + + .docs-nav a { + font-size: var(--t-small); + color: var(--ink-2); + text-decoration: none; + padding: 5px 8px; + border-radius: var(--r-ctl); + border-inline-start: 2px solid transparent; + } + + .docs-nav a:hover { + background-color: var(--hover); + color: var(--ink); + } + + .docs-nav a:target, + .docs-nav a:focus-visible { + color: var(--accent); + } + + .docs-body { + min-width: 0; + display: grid; + gap: var(--s-xl); + max-width: 74ch; + } + + .docs-lede { + display: grid; + gap: var(--s-xs); + padding-bottom: var(--s-md); + border-bottom: 1px solid var(--line); + } + + .docs-lede h1 { + font-size: 1.5rem; + font-weight: 650; + letter-spacing: -0.025em; + margin: 0; + text-wrap: balance; + } + + .docs-lede p { + margin: 0; + font-size: var(--t-body); + line-height: 1.6; + color: var(--ink-2); + } + + .docs-meta { + display: flex; + flex-wrap: wrap; + gap: var(--s-2xs) var(--s-md); + margin: var(--s-2xs) 0 0; + } + + .docs-meta div { + display: grid; + gap: 1px; + } + + .docs-meta dt { + font-family: var(--font-code); + font-size: var(--t-micro); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-4); + } + + .docs-meta dd { + margin: 0; + font-size: var(--t-small); + font-weight: 500; + } + + .docs-sec { + display: grid; + gap: var(--s-xs); + scroll-margin-top: var(--s-sm); + } + + .docs-sec h2 { + display: flex; + align-items: center; + gap: var(--s-2xs); + font-size: var(--t-title); + font-weight: 620; + letter-spacing: -0.018em; + margin: 0; + } + + .docs-sec p { + margin: 0; + font-size: var(--t-body); + line-height: 1.65; + color: var(--ink-2); + } + + .docs-sec p.lede { + color: var(--ink); + font-weight: 500; + } + + .docs-sec code { + font-family: var(--font-code); + font-size: 0.86em; + background-color: var(--card-2); + border: 1px solid var(--line-soft); + border-radius: 4px; + padding: 0 4px; + } + + .docs-sec .data-table th, + .docs-sec .data-table td { + width: 1%; + white-space: nowrap; + vertical-align: top; + } + + .docs-sec .data-table th.said-cell, + .docs-sec .data-table td.said-cell { + width: auto; + white-space: normal; + padding-inline-start: var(--s-md); + } + + .docs-sec .data-table td.mono { + color: var(--ink); + } + + .docs-sec .scroll { + border: 1px solid var(--line); + border-radius: var(--r-card); + background-color: var(--card); + } + + .docs-note { + border-inline-start: 2px solid var(--accent); + padding-inline-start: var(--s-xs); + } + + .docs-case { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 5px; + min-width: 0; + } + + .docs-case .pre { + height: 100%; + } + + .docs-case-h { + font-size: var(--t-fine) !important; + font-weight: 600 !important; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-3) !important; + } + + .verb { + font-family: var(--font-code); + font-size: var(--t-micro); + font-weight: 700; + letter-spacing: 0.08em; + padding: 3px 6px; + border-radius: 4px; + background-color: var(--accent-soft); + color: var(--accent); + flex: none; + } + + .pre { + margin: 0; + padding: 12px 14px; + border-radius: var(--r-card); + overflow-x: auto; + background-color: var(--card); + border: 1px solid var(--line); + font-family: var(--font-code); + font-size: var(--t-fine); + line-height: 1.7; + color: var(--ink); + } + + .pre .k { color: var(--accent); } + .pre .s { color: var(--good); } + .pre .c { color: var(--ink-3); font-style: italic; } + .pre .n { color: var(--warn); } + + .two-up { + display: grid; + gap: var(--s-xs); + grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr)); + } + .num-in { font-family: var(--font-code); font-variant-numeric: tabular-nums; diff --git a/web/app/controllers/api/v1/base_controller.rb b/web/app/controllers/api/v1/base_controller.rb index 692c544f..cfcce228 100644 --- a/web/app/controllers/api/v1/base_controller.rb +++ b/web/app/controllers/api/v1/base_controller.rb @@ -56,16 +56,23 @@ def presented end SAID = { - "api_off" => "the mnemosyne api is turned off", - "invalid_token" => "no live token matches that key", - "revoked_token" => "that token was revoked and will not come back", - "bad_channel_id" => "a channel id looks like C0123456789", - "bad_user_id" => "a user id looks like U0123456789", - "channel_not_found" => "no public channel with that id", - "too_many_subjects" => "ask about fewer people in one call", - "rate_limited" => "too many calls this minute, wait and try again" + "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", + "bad_channel_id" => "channel_id must match /\\AC[A-Z0-9]{8,}\\z/", + "bad_user_id" => "every user id must match /\\A[UW][A-Z0-9]{8,}\\z/", + "rate_limited" => "rate limit spent, see retry_after" }.freeze + CALLER_ERRORS = [ + [401, "invalid_token", "Absent, malformed, or unknown Authorization header."], + [401, "revoked_token", "Key exists but was revoked. Not retryable."], + [422, "bad_channel_id", "channel_id did not match the pattern above."], + [422, "bad_user_id", "user_id did not match the pattern above."], + [429, "rate_limited", "Budget spent. Retry after retry_after seconds."], + [503, "api_off", "The public_api flag is off. Applies to every route."] + ].freeze + def refuse(status, error, **extra) render json: { error: error, message: SAID[error] }.compact.merge(extra), status: status end diff --git a/web/app/controllers/api/v1/channel_managers_controller.rb b/web/app/controllers/api/v1/channel_managers_controller.rb index 611ef8ba..c822735d 100644 --- a/web/app/controllers/api/v1/channel_managers_controller.rb +++ b/web/app/controllers/api/v1/channel_managers_controller.rb @@ -11,80 +11,38 @@ def show return refuse(:unprocessable_content, "bad_channel_id") unless CHANNEL.match?(channel) return refuse(:unprocessable_content, "bad_user_id") unless MEMBER.match?(user) - return refuse(:not_found, "channel_not_found") unless known?(channel) - found = answers(channel, [user]) - render json: { channel_id: channel }.merge(found[:results].first).merge(found[:about]) - end - - def check - channel = params[:channel_id].to_s - users = Array(params[:user_ids]).map(&:to_s) - most = ::Api::Setting.value("batch_max") - - return refuse(:unprocessable_content, "bad_channel_id") unless CHANNEL.match?(channel) - return refuse(:unprocessable_content, "too_many_subjects", most: most) if users.size > most - return refuse(:unprocessable_content, "bad_user_id") unless users.all? { MEMBER.match?(_1) } - return refuse(:not_found, "channel_not_found") unless known?(channel) - - found = answers(channel, users.uniq) - render json: { channel_id: channel }.merge(found[:about]).merge(results: found[:results]) + render json: { channel_id: channel, user_id: user }.merge(answer(channel, user)) end private - def known?(channel_id) - Analytics::DimChannel.exists?(channel_id: channel_id) - end - - def answers(channel, users) - granted = ::Api::Consent.where(user_id: users, capability: CAPABILITY, - state: ::Api::Consent::GRANTED).pluck(:user_id).to_set - held = held_by(channel, granted) - swept = granted.any? ? ChannelManagers.swept_at(channel) : nil - - outcomes = [] - results = users.map do |user| - row = one(user, granted, held) - outcomes << [user, outcome_of(row)] - row - end - - ::Api::RequestLog.log!(current_token.id, channel, outcomes) - { results: results, about: about(granted, swept) } - end - - def held_by(channel, granted) - return {} if granted.empty? + def answer(channel, user) + return withheld(channel, user) unless ::Api::Consent.granted?(user, CAPABILITY) ChannelManagers.freshen(channel) - ::Api::ChannelManager.where(channel_id: channel, user_id: granted.to_a) - .pluck(:user_id, :assigned_at).to_h - end - - def one(user, granted, held) - unless granted.include?(user) - return { user_id: user, consent: "withheld", is_manager: nil, - opt_in_url: "#{request.base_url}/you/api" } - end + held = ::Api::ChannelManager.find_by(channel_id: channel, user_id: user) + log(channel, user, held ? ::Api::RequestLog::MANAGER : ::Api::RequestLog::NOT_MANAGER) - row = { user_id: user, consent: "granted", is_manager: held.key?(user) } - row[:since] = held[user]&.utc&.iso8601 if held.key?(user) - row + { consent: "granted", is_manager: held.present? }.merge(freshness(channel)) end - def outcome_of(row) - return ::Api::RequestLog::WITHHELD if row[:consent] == "withheld" + def withheld(channel, user) + log(channel, user, ::Api::RequestLog::WITHHELD) - row[:is_manager] ? ::Api::RequestLog::MANAGER : ::Api::RequestLog::NOT_MANAGER + { consent: "withheld", is_manager: nil, opt_in_url: "#{request.base_url}/you/api" } end - def about(granted, swept) - return {} if granted.empty? + 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/docs_controller.rb b/web/app/controllers/docs_controller.rb new file mode 100644 index 00000000..66e9bc93 --- /dev/null +++ b/web/app/controllers/docs_controller.rb @@ -0,0 +1,5 @@ +class DocsController < You::BaseController + def show + @rate = Api::Setting.value("rate_per_minute") + end +end diff --git a/web/app/models/api/setting.rb b/web/app/models/api/setting.rb index 5ab24a1e..8360763a 100644 --- a/web/app/models/api/setting.rb +++ b/web/app/models/api/setting.rb @@ -5,7 +5,6 @@ class Setting < ApplicationRecord DEFAULTS = { "rate_per_minute" => 20, - "batch_max" => 100, "tokens_per_owner" => 3 }.freeze diff --git a/web/app/models/docs.rb b/web/app/models/docs.rb new file mode 100644 index 00000000..daddbb05 --- /dev/null +++ b/web/app/models/docs.rb @@ -0,0 +1,24 @@ +class Docs + Section = Struct.new(:id, :title, keyword_init: true) + Topic = Struct.new(:slug, :title, :blurb, :sections, keyword_init: true) + + CHANNEL_MANAGERS = Topic.new( + slug: "channel-managers", + title: "Channel manager API", + blurb: "Resolves whether a member holds the channel manager role on a public channel. " \ + "Gated on that member's consent. Returns nothing else about them.", + sections: [ + Section.new(id: "auth", title: "Authentication"), + Section.new(id: "check", title: "Check a member"), + Section.new(id: "consent", title: "Consent states"), + Section.new(id: "rate", title: "Rate limits"), + Section.new(id: "errors", title: "Errors") + ] + ) + + ALL = [CHANNEL_MANAGERS].freeze + + def self.topics = ALL + + def self.section_ids = ALL.flat_map { |topic| topic.sections.map(&:id) } +end diff --git a/web/app/views/docs/_channel_managers.html.erb b/web/app/views/docs/_channel_managers.html.erb new file mode 100644 index 00000000..2dc68615 --- /dev/null +++ b/web/app/views/docs/_channel_managers.html.erb @@ -0,0 +1,159 @@ +<% channel_pattern = Api::V1::ChannelManagersController::CHANNEL.source %> +<% member_pattern = Api::V1::ChannelManagersController::MEMBER.source %> + +
+

<%= topic.title %>

+

<%= topic.blurb %>

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

Authentication

+
Authorization: Bearer nemo_live_7Fj2QcW8xR4mLpVdA1sT
+

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

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

GET Check a member

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

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

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

200 · consent granted

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

200 · consent withheld

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

Rate limits

+

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

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

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

+
+ +
+

Errors

+
{ "error": "rate_limited", "message": "rate limit spent, see retry_after", "retry_after": 34 }
+
+ + + + + + <% Api::V1::BaseController::CALLER_ERRORS.each do |status, key, said| %> + + + + + + <% end %> + +
statuserrorcause
<%= status %><%= key %><%= said %>
+
+
diff --git a/web/app/views/docs/show.html.erb b/web/app/views/docs/show.html.erb new file mode 100644 index 00000000..99f514cd --- /dev/null +++ b/web/app/views/docs/show.html.erb @@ -0,0 +1,19 @@ +<% content_for :page_title, "Docs" %> +<% content_for :head_actions do %> + <%= link_to "Your tokens", you_api_path(tab: "tokens"), class: "btn" %> +<% end %> + +
+ + +
+ <%= render "docs/channel_managers", topic: Docs::CHANNEL_MANAGERS %> +
+
diff --git a/web/app/views/layouts/application.html.erb b/web/app/views/layouts/application.html.erb index 8b54b649..007ede10 100644 --- a/web/app/views/layouts/application.html.erb +++ b/web/app/views/layouts/application.html.erb @@ -158,9 +158,8 @@
- <% if fire_engine %> -
- <% if current_staff&.may?("access.read") %> +
+ <% if fire_engine && current_staff&.may?("access.read") %> <%= link_to fd_audit_path, class: "rail-item", aria: { current: ("page" if controller_path == "fd/audits") } do %>
- <% end %> + <% end %> +
diff --git a/web/config/routes.rb b/web/config/routes.rb index e8a6588a..f9f2205b 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -13,13 +13,13 @@ namespace :api do namespace :v1 do resource :token, only: [:show], controller: "tokens" - post "channels/:channel_id/managers/check", to: "channel_managers#check", - as: :channel_managers_check get "channels/:channel_id/managers/:user_id", to: "channel_managers#show", as: :channel_manager end end + get "docs", to: "docs#show", as: :docs + namespace :you do get "api", to: "api#show", as: :api resource :consent, only: [:update], controller: "consents" diff --git a/web/test/integration/api_channel_managers_test.rb b/web/test/integration/api_channel_managers_test.rb index 0f6fb750..1d744ee4 100644 --- a/web/test/integration/api_channel_managers_test.rb +++ b/web/test/integration/api_channel_managers_test.rb @@ -37,14 +37,13 @@ def ask(user_id, on: @channel) get api_v1_channel_manager_path(channel_id: on, user_id: user_id), headers: head end - def batch(user_ids, on: @channel) - post api_v1_channel_managers_check_path(channel_id: on), - params: { user_ids: user_ids }.to_json, - headers: head.merge("Content-Type" => "application/json") - end - def body = JSON.parse(response.body) + def body_of + yield + body + end + test "an opted in manager is answered yes, with when and how fresh" do opted_in(MANAGER) ask(MANAGER) @@ -54,16 +53,15 @@ def body = JSON.parse(response.body) "consent", "is_manager") assert_not body["stale"] assert body["synced_at"].present? - assert body["since"].present? + assert_not body.key?("since"), "when somebody became a manager is not the caller's business" end - test "an opted in bystander is answered no, and carries no since" do + test "an opted in bystander is answered no" do opted_in(BYSTANDER) ask(BYSTANDER) assert_response :success assert_equal ["granted", false], body.values_at("consent", "is_manager") - assert_not body.key?("since") end test "somebody who never opted in is withheld, and told nothing else" do @@ -92,13 +90,30 @@ def body = JSON.parse(response.body) assert_not body.key?("stale") end - test "a channel we do not hold is not found, before consent is even looked at" do + test "a private channel is answered like any other, because the member opted in" do opted_in(MANAGER) - ask(MANAGER, on: "CDOESNOTEXIST") + manages(MANAGER, on: "C0PRIVATE99") + ask(MANAGER, on: "C0PRIVATE99") - assert_response :not_found - assert_equal "channel_not_found", body["error"] - assert_empty Api::RequestLog.all, "a 404 is not an ask about anybody" + assert_response :success + assert_equal ["granted", true], body.values_at("consent", "is_manager") + end + + test "a channel we hold nothing on answers no, not whether it exists" do + opted_in(MANAGER) + ask(MANAGER, on: "C0NOTHINGHERE") + + assert_response :success + assert_equal ["granted", false], body.values_at("consent", "is_manager") + end + + test "consent is settled before the channel, so an opted out ask reveals no channel" do + real = body_of { ask(MANAGER, on: @channel) } + made_up = body_of { ask(MANAGER, on: "C0NOSUCHTHING") } + + assert_equal "withheld", real["consent"] + assert_equal real.except("channel_id"), made_up.except("channel_id"), + "a private or unknown channel must look the same while consent is withheld" end test "a malformed id is refused before anything is read" do @@ -109,6 +124,7 @@ def body = JSON.parse(response.body) ask(MANAGER, on: "nope") assert_response :unprocessable_content assert_equal "bad_channel_id", body["error"] + assert_empty Api::RequestLog.all, "a malformed id is not an ask about anybody" end test "every ask is written down, with what it was told" do @@ -121,41 +137,14 @@ def body = JSON.parse(response.body) assert_equal [@token.id], Api::RequestLog.distinct.pluck(:token_id) end - test "a batch answers each subject and costs one row each" do - opted_in(MANAGER) - batch([MANAGER, BYSTANDER]) - - assert_response :success - assert_equal @channel, body["channel_id"] - assert_equal [MANAGER, BYSTANDER], body["results"].map { _1["user_id"] } - assert_equal [true, nil], body["results"].map { _1["is_manager"] } - assert_equal 2, Api::RequestLog.count - end - - test "a batch over the cap is refused and says what the cap is" do - batch(Array.new(101) { |i| format("U%010d", i) }) - - assert_response :unprocessable_content - assert_equal "too_many_subjects", body["error"] - assert_equal 100, body["most"] - assert_empty Api::RequestLog.all - end - - test "one bad id spoils the batch rather than being quietly dropped" do - batch([MANAGER, "nope"]) - - assert_response :unprocessable_content - assert_equal "bad_user_id", body["error"] - end - - test "a batch nobody consented to reads nothing about the channel" do + test "a withheld ask reads nothing about the channel" do asked = [] was = ChannelManagers.method(:freshen) ChannelManagers.define_singleton_method(:freshen) { |id| asked << id } - batch([MANAGER, BYSTANDER]) + ask(MANAGER) - assert_empty asked, "a wholly withheld batch must not touch slack" - assert_equal 2, Api::RequestLog.where(outcome: "withheld").count + assert_empty asked, "consent is checked before slack is ever troubled" + assert_equal 1, Api::RequestLog.where(outcome: "withheld").count ensure ChannelManagers.define_singleton_method(:freshen, was) end diff --git a/web/test/integration/api_docs_test.rb b/web/test/integration/api_docs_test.rb new file mode 100644 index 00000000..58a65499 --- /dev/null +++ b/web/test/integration/api_docs_test.rb @@ -0,0 +1,65 @@ +require "test_helper" + +class ApiDocsTest < ActionDispatch::IntegrationTest + setup do + @member = Staff.create!(user_id: "UMEMBER3") + end + + test "any signed in member can read the docs, role or no role" do + sign_in_as(@member) + get docs_path + + assert_response :success + assert_select ".docs-sec", Docs.section_ids.size + assert_select ".rail-item[href=?]", docs_path + end + + test "signed out, the docs ask you to sign in" do + get docs_path + + assert_redirected_to login_path + end + + test "the rate it quotes is the one actually in force" do + Api::Setting.set!("rate_per_minute", 45, by: "UBOSS") + sign_in_as(@member) + get docs_path + + assert_select ".docs-meta dd", text: "45/min per key" + assert_select ".pre", text: /RateLimit-Limit: 45/ + end + + test "every error the api can return is written down, and nothing else" do + sign_in_as(@member) + get docs_path + + listed = css_select("#errors .data-table tbody td:nth-child(2)").map(&:text) + assert_equal Api::V1::BaseController::CALLER_ERRORS.map { |_status, key, _said| key }.sort, + listed.sort + end + + test "the contents and the page cannot drift apart" do + sign_in_as(@member) + get docs_path + + listed = css_select(".docs-nav a").map { |link| link["href"].delete_prefix("#") } + rendered = css_select(".docs-sec").map { |sec| sec["id"] } + + assert_equal Docs.section_ids, listed, "the contents list every section, in order" + assert_equal Docs.section_ids, rendered, "and every one of them is on the page" + end + + test "a topic names its sections once, so a duplicate anchor cannot creep in" do + assert_equal Docs.section_ids.uniq, Docs.section_ids + end + + test "the docs never name a key somebody actually holds" do + sign_in_as(@member) + _token, secret = Api::Token.mint!(@member.user_id, "Toolbox") + get docs_path + + assert_no_match(/#{Regexp.escape(secret)}/, response.body, + "a real key must not leak into the examples") + assert_match(/nemo_live_7Fj2/, response.body, "the example key is a made up one") + end +end diff --git a/web/test/integration/who_gets_in_test.rb b/web/test/integration/who_gets_in_test.rb index ac08dff8..11750434 100644 --- a/web/test/integration/who_gets_in_test.rb +++ b/web/test/integration/who_gets_in_test.rb @@ -8,7 +8,7 @@ class WhoGetsInTest < ActionDispatch::IntegrationTest INSIDE = %i[root_path fd_root_path fd_members_path fd_decisions_path fd_settings_path].freeze - MEMBER = %w[you/api you/consents you/tokens].freeze + MEMBER = %w[you/api you/consents you/tokens docs].freeze BEARER = %w[api/v1/tokens api/v1/channel_managers].freeze From 6a26c78a5a80930baee93cb845a9f7effee3e269 Mon Sep 17 00:00:00 2001 From: "Abdallah Ebrahim (dracula)" Date: Mon, 31 Aug 2026 02:55:12 +0300 Subject: [PATCH 15/16] web(css): generate token modal --- db/migrations/0059_api_token_expiry.sql | 4 + web/app/assets/tailwind/application.css | 74 +++++++++++ web/app/controllers/api/v1/base_controller.rb | 3 + web/app/controllers/you/tokens_controller.rb | 5 +- web/app/helpers/fd_helper.rb | 6 + web/app/models/api/token.rb | 45 ++++++- .../views/fd/settings/_api_modals.html.erb | 2 +- web/app/views/fd/settings/show.html.erb | 8 +- web/app/views/you/api/_kill.html.erb | 4 +- web/app/views/you/api/_mint.html.erb | 55 ++++---- web/app/views/you/api/show.html.erb | 16 ++- web/test/integration/member_tokens_test.rb | 121 ++++++++++++++++++ 12 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 db/migrations/0059_api_token_expiry.sql create mode 100644 web/test/integration/member_tokens_test.rb diff --git a/db/migrations/0059_api_token_expiry.sql b/db/migrations/0059_api_token_expiry.sql new file mode 100644 index 00000000..c36629a5 --- /dev/null +++ b/db/migrations/0059_api_token_expiry.sql @@ -0,0 +1,4 @@ +ALTER TABLE api.token ADD COLUMN expires_at timestamptz; + +CREATE INDEX token_expiry_idx ON api.token (expires_at) + WHERE revoked_at IS NULL AND expires_at IS NOT NULL; diff --git a/web/app/assets/tailwind/application.css b/web/app/assets/tailwind/application.css index fa9779e1..95eccf61 100644 --- a/web/app/assets/tailwind/application.css +++ b/web/app/assets/tailwind/application.css @@ -2803,6 +2803,80 @@ color: var(--accent-on); } + .drop { + position: relative; + display: grid; + } + + .drop::after { + content: ""; + position: absolute; + inset-inline-end: 12px; + top: 50%; + width: 8px; + height: 8px; + border-inline-end: 2px solid var(--ink-3); + border-block-end: 2px solid var(--ink-3); + transform: translateY(-70%) rotate(45deg); + pointer-events: none; + } + + .drop-in { + appearance: none; + width: 100%; + font-family: inherit; + font-size: var(--t-body); + line-height: 1.25; + color: var(--ink); + background-color: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-ctl); + padding: 9px 32px 9px var(--s-xs); + cursor: pointer; + transition: border-color var(--dur) var(--ease-out); + } + + .drop-in:hover { + border-color: var(--ink-3); + } + + .drop-in:focus-visible { + outline: none; + border-color: var(--focus); + box-shadow: 0 0 0 3px var(--accent-soft); + } + + .drop:has(.drop-in:hover)::after { + border-color: var(--ink); + } + + .form-body { + gap: var(--s-md); + } + + .form-body .field-label { + gap: 6px; + } + + .warn-line { + display: flex; + align-items: flex-start; + gap: var(--s-2xs); + margin: 0; + padding: 10px 12px; + border-radius: var(--r-ctl); + background-color: var(--warn-soft); + color: var(--warn); + font-size: var(--t-small); + font-weight: 500; + line-height: 1.45; + } + + .warn-line svg { + flex: none; + margin-top: 1px; + } + .secret { display: flex; align-items: center; diff --git a/web/app/controllers/api/v1/base_controller.rb b/web/app/controllers/api/v1/base_controller.rb index cfcce228..25639ab6 100644 --- a/web/app/controllers/api/v1/base_controller.rb +++ b/web/app/controllers/api/v1/base_controller.rb @@ -46,6 +46,7 @@ def require_a_token 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! @@ -59,6 +60,7 @@ def presented "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" @@ -67,6 +69,7 @@ def presented CALLER_ERRORS = [ [401, "invalid_token", "Absent, malformed, or unknown Authorization header."], [401, "revoked_token", "Key exists but was revoked. Not retryable."], + [401, "expired_token", "Key passed its expiry date. Mint a new one."], [422, "bad_channel_id", "channel_id did not match the pattern above."], [422, "bad_user_id", "user_id did not match the pattern above."], [429, "rate_limited", "Budget spent. Retry after retry_after seconds."], diff --git a/web/app/controllers/you/tokens_controller.rb b/web/app/controllers/you/tokens_controller.rb index 1c056189..756b9bc3 100644 --- a/web/app/controllers/you/tokens_controller.rb +++ b/web/app/controllers/you/tokens_controller.rb @@ -4,9 +4,10 @@ def create return refuse(turned_off) if Fd::Flag.off?(:public_api) return refuse("name what the token is for") if params[:name].to_s.strip.empty? - token, @secret = Api::Token.mint!(member_id, params[:name]) + token, @secret = Api::Token.mint!(member_id, params[:name], lasting: params[:lasting]) Api::Event.record!("token_minted", actor: member_id, subject: token.shown, - detail: token.name) + detail: [token.name, Api::Token::LIFE_WORDS.fetch(Api::Token.life_for(params[:lasting]))] + .join(", ")) @token = token show_again diff --git a/web/app/helpers/fd_helper.rb b/web/app/helpers/fd_helper.rb index aac467a9..588ae250 100644 --- a/web/app/helpers/fd_helper.rb +++ b/web/app/helpers/fd_helper.rb @@ -1235,6 +1235,12 @@ def deed_said(deed) "tokens_per_owner" => "Live tokens per owner" }.freeze + def token_life_line(token) + return "never expires" if token.expires_at.nil? + + "expires #{token.expires_at.strftime('%-d %b %Y')}" + end + def dial_label(key) DIAL_LABELS.fetch(key, key.tr("_", " ")) end diff --git a/web/app/models/api/token.rb b/web/app/models/api/token.rb index bfc4d786..61cccef9 100644 --- a/web/app/models/api/token.rb +++ b/web/app/models/api/token.rb @@ -12,7 +12,25 @@ class Token < ApplicationRecord class TooMany < StandardError; end - scope :live, -> { where(revoked_at: nil) } + 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) @@ -30,12 +48,22 @@ def self.secret LEAD + Array.new(LENGTH) { ALPHABET[SecureRandom.random_number(ALPHABET.size)] }.join end - def self.mint!(owner_user_id, name) + def self.life_for(asked) + LIVES.key?(asked.to_s) ? asked.to_s : DEFAULT_LIFE + end + + def self.dies_on(asked) + span = LIVES.fetch(life_for(asked)) + span && span.from_now + end + + def self.mint!(owner_user_id, name, lasting: DEFAULT_LIFE) raise TooMany unless room_for?(owner_user_id) key = secret row = create!(owner_user_id: owner_user_id, name: name.to_s.strip.first(MAX_NAME), - prefix: key.first(LEAD.length + SHOWN), digest: digest_of(key)) + prefix: key.first(LEAD.length + SHOWN), digest: digest_of(key), + expires_at: dies_on(lasting)) [row, key] end @@ -43,6 +71,10 @@ def self.mint!(owner_user_id, name) 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 @@ -60,5 +92,12 @@ def rate 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/views/fd/settings/_api_modals.html.erb b/web/app/views/fd/settings/_api_modals.html.erb index ced45c26..f96e077a 100644 --- a/web/app/views/fd/settings/_api_modals.html.erb +++ b/web/app/views/fd/settings/_api_modals.html.erb @@ -30,7 +30,7 @@ <% end %> <% end %> -<% @tokens.reject(&:revoked?).each do |token| %> +<% @tokens.reject(&:spent?).each do |token| %> <%= render layout: "fd/modal", locals: { id: "rate-#{token.id}", title: "Rate for #{token.name}", sub: "owned by #{names[token.owner_user_id]}" } do %> <%= form_with url: fd_api_token_rate_path(token), method: :patch, diff --git a/web/app/views/fd/settings/show.html.erb b/web/app/views/fd/settings/show.html.erb index 71a18230..1f853062 100644 --- a/web/app/views/fd/settings/show.html.erb +++ b/web/app/views/fd/settings/show.html.erb @@ -100,7 +100,7 @@

Live tokens

-

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

+

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

across <%= @tokens.map(&:owner_user_id).uniq.size %> owners

@@ -150,8 +150,8 @@ Every token - <%= @tokens.count { |one| !one.revoked? } %> live · - <%= @tokens.count(&:revoked?) %> revoked + <%= @tokens.count { |one| !one.spent? } %> live · + <%= @tokens.count(&:spent?) %> spent
@@ -164,6 +164,7 @@ name key rate + expires asks 30d last used @@ -183,6 +184,7 @@ "> <%= 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" %> diff --git a/web/app/views/you/api/_kill.html.erb b/web/app/views/you/api/_kill.html.erb index f54458ef..2c90538f 100644 --- a/web/app/views/you/api/_kill.html.erb +++ b/web/app/views/you/api/_kill.html.erb @@ -8,8 +8,8 @@

- created - <%= token.created_at.strftime("%-d %b %Y") %> + expires + <%= token_life_line(token).delete_prefix("expires ") %>
last used diff --git a/web/app/views/you/api/_mint.html.erb b/web/app/views/you/api/_mint.html.erb index 40eb0ded..e7868924 100644 --- a/web/app/views/you/api/_mint.html.erb +++ b/web/app/views/you/api/_mint.html.erb @@ -1,53 +1,46 @@ -<%= render layout: "fd/modal", locals: { id: "mint-token", title: "Generate a token", - sub: "shown once", open: @secret.present? } do %> +<%= render layout: "fd/modal", locals: { id: "mint-token", + title: @secret.present? ? "Copy your key" : "New API key", + open: @secret.present? } do %> <% if @secret.present? %> <% else %> - <%= form_with url: you_tokens_path, method: :post, html: { autocomplete: "off" } do %> -