diff --git a/lib/textbin/organizations.ex b/lib/textbin/organizations.ex index 9ef79ab..c897aef 100644 --- a/lib/textbin/organizations.ex +++ b/lib/textbin/organizations.ex @@ -1345,6 +1345,8 @@ defmodule Textbin.Organizations do end defp record_audit(organization_id, actor_user_id, action, target_type, target_id, metadata) do + metadata = snapshot_audit_metadata(metadata, actor_user_id, target_type, target_id) + %AuditEvent{} |> AuditEvent.changeset(%{ organization_id: organization_id, @@ -1361,6 +1363,61 @@ defmodule Textbin.Organizations do end end + # Audit labels are snapshots: mutable or deleted records must not rewrite historical context. + defp snapshot_audit_metadata(metadata, actor_user_id, target_type, target_id) do + metadata + |> put_audit_label("actor_email", user_email(actor_user_id)) + |> put_target_audit_label(target_type, target_id) + |> put_workspace_audit_label() + end + + defp put_target_audit_label(metadata, "user", target_id), + do: put_audit_label(metadata, "target_email", user_email(target_id)) + + defp put_target_audit_label(metadata, "workspace", target_id) do + name = + case Repo.get(Workspace, target_id) do + %Workspace{name: name} -> name + nil -> metadata["name"] + end + + put_audit_label(metadata, "target_name", name) + end + + defp put_target_audit_label(metadata, "organization", target_id) do + name = + case Repo.get(Organization, target_id) do + %Organization{name: name} -> name + nil -> nil + end + + put_audit_label(metadata, "target_name", name) + end + + defp put_target_audit_label(metadata, _target_type, _target_id), do: metadata + + defp put_workspace_audit_label(%{"workspace_id" => workspace_id} = metadata) do + name = + case Repo.get(Workspace, workspace_id) do + %Workspace{name: name} -> name + nil -> nil + end + + put_audit_label(metadata, "workspace_name", name) + end + + defp put_workspace_audit_label(metadata), do: metadata + + defp put_audit_label(metadata, _key, nil), do: metadata + defp put_audit_label(metadata, key, value), do: Map.put_new(metadata, key, value) + + defp user_email(user_id) do + case Repo.get(User, user_id) do + %User{email: email} -> email + nil -> nil + end + end + defp record_role_change_audit( _organization_id, _actor_user_id, diff --git a/lib/textbin_web/components/layouts.ex b/lib/textbin_web/components/layouts.ex index 283bc08..7830e61 100644 --- a/lib/textbin_web/components/layouts.ex +++ b/lib/textbin_web/components/layouts.ex @@ -83,6 +83,7 @@ defmodule TextbinWeb.Layouts do <.mobile_more_navigation scope={@current_scope} workspaces={@navigation_workspaces} + active={@active_navigation} /> @@ -179,8 +180,15 @@ defmodule TextbinWeb.Layouts do + + + + + """ + end + + defp resolve_organization_scope!(scope, organization_slug) do + case Organizations.resolve_organization_scope_by_slug(scope, organization_slug) do + {:ok, resolved_scope} -> resolved_scope + {:error, :not_found} -> raise Ecto.NoResultsError, queryable: Organization + end + end + + defp audit_page(scope, cursor \\ nil) do + Organizations.list_audit_event_page(scope, scope.organization, + limit: @page_size, + cursor: cursor + ) + end + + defp deny_access(socket) do + socket + |> put_flash(:error, "Only organization owners can view the audit log") + |> push_navigate(to: organization_path(socket.assigns.current_scope.organization)) + end + + defp conceal_access(socket) do + socket + |> assign(:next_cursor, nil) + |> stream(:audit_events, [], reset: true) + |> put_flash(:error, "The organization could not be found") + |> push_navigate(to: ~p"/orgs") + end + + defp actor_label(%AuditEvent{metadata: %{"actor_email" => email}}), do: email + defp actor_label(%AuditEvent{actor_user_id: id}), do: "User · #{short_id(id)}" + + defp target_label(%AuditEvent{metadata: %{"target_email" => email}}), do: email + defp target_label(%AuditEvent{metadata: %{"target_name" => name}}), do: name + + defp target_label(%AuditEvent{target_type: type, target_id: id}), + do: "#{String.capitalize(type)} · #{short_id(id)}" + + defp workspace_label(%AuditEvent{metadata: %{"workspace_name" => name}}), do: name + defp workspace_label(_event), do: nil + + defp event_title("workspace.created"), do: "Workspace created" + defp event_title("workspace.deleted"), do: "Workspace deleted" + defp event_title("workspace.membership.added"), do: "Workspace member added" + defp event_title("workspace.membership.removed"), do: "Workspace member removed" + defp event_title("workspace.membership.role_changed"), do: "Workspace role changed" + defp event_title("workspace.visibility_changed"), do: "Workspace visibility changed" + + defp event_title("workspace.external_sharing_policy_changed"), + do: "External sharing policy changed" + + defp event_title("workspace.recovery_access_granted"), do: "Workspace access recovered" + defp event_title("organization.membership.added"), do: "Organization member added" + defp event_title("organization.membership.removed"), do: "Organization member removed" + defp event_title("organization.membership.role_changed"), do: "Organization role changed" + defp event_title("organization.name_changed"), do: "Organization renamed" + + defp event_title(action), + do: action |> String.replace([".", "_"], " ") |> String.capitalize() + + defp event_description(%AuditEvent{ + action: "workspace.created", + metadata: %{"visibility" => visibility} + }), + do: "Created #{visibility_article(visibility)} #{visibility} workspace." + + defp event_description(%AuditEvent{ + action: "workspace.deleted", + metadata: %{"name" => name} + }), + do: "Deleted the workspace “#{name}”." + + defp event_description(%AuditEvent{ + action: "organization.name_changed", + metadata: %{"old" => old, "new" => new} + }), + do: "Changed the organization name from “#{old}” to “#{new}”." + + defp event_description(%AuditEvent{ + action: action, + metadata: %{"old_role" => old, "new_role" => new} + }) + when action in [ + "organization.membership.role_changed", + "workspace.membership.role_changed" + ], + do: "Changed the member role from #{old} to #{new}." + + defp event_description(%AuditEvent{ + action: action, + metadata: %{"role" => role, "self" => true} + }) + when action in ["organization.membership.removed", "workspace.membership.removed"], + do: "A #{role} left voluntarily." + + defp event_description( + %AuditEvent{ + action: "workspace.membership.added", + metadata: %{"role" => role} = metadata + } = event + ), + do: "Granted #{role} access to #{target_label(event)}#{workspace_description(metadata)}." + + defp event_description( + %AuditEvent{ + action: "organization.membership.added", + metadata: %{"role" => role} + } = event + ), + do: "Granted #{role} access to #{target_label(event)}." + + defp event_description(%AuditEvent{ + action: action, + metadata: %{"role" => role} + }) + when action in ["organization.membership.removed", "workspace.membership.removed"], + do: "Removed a member who had the #{role} role." + + defp event_description(%AuditEvent{ + action: "organization.membership.removed", + metadata: %{"reason" => "account_deleted"} + }), + do: "Removed a member after their account was deleted." + + defp event_description(%AuditEvent{ + action: "workspace.visibility_changed", + metadata: %{"old" => old, "new" => new} + }), + do: "Changed workspace visibility from #{old} to #{new}." + + defp event_description(%AuditEvent{ + action: "workspace.external_sharing_policy_changed", + metadata: %{"old" => old, "new" => new} + }), + do: "Changed external sharing from #{old} to #{new}." + + defp event_description(%AuditEvent{ + action: "workspace.recovery_access_granted", + metadata: %{"role" => role} + }), + do: "Recovered workspace access with the #{role} role." + + defp event_description(event), + do: "Recorded an administrative change to this #{event.target_type}." + + defp event_icon(action) when action in ["workspace.created", "workspace.membership.added"], + do: "hero-plus-circle" + + defp event_icon(action) when action in ["workspace.deleted", "workspace.membership.removed"], + do: "hero-minus-circle" + + defp event_icon(action) when action in ["organization.membership.added"], + do: "hero-user-plus" + + defp event_icon(action) when action in ["organization.membership.removed"], + do: "hero-user-minus" + + defp event_icon(action) + when action in [ + "organization.membership.role_changed", + "workspace.membership.role_changed" + ], + do: "hero-arrows-right-left" + + defp event_icon("workspace.recovery_access_granted"), do: "hero-shield-check" + defp event_icon(_action), do: "hero-cog-6-tooth" + + defp short_id(id) when is_binary(id), do: String.slice(id, 0, 8) + defp short_id(_id), do: "unknown" + + defp visibility_article("open"), do: "an" + defp visibility_article(_visibility), do: "a" + + defp workspace_description(%{"workspace_name" => name}), do: " in “#{name}”" + defp workspace_description(_metadata), do: "" + + defp organization_path(organization), do: "/o/#{organization.slug}" +end diff --git a/lib/textbin_web/router.ex b/lib/textbin_web/router.ex index 0632ae0..cb04dc8 100644 --- a/lib/textbin_web/router.ex +++ b/lib/textbin_web/router.ex @@ -84,6 +84,7 @@ defmodule TextbinWeb.Router do live "/o/:organization_slug", UI.OrganizationLive, :show live "/o/:organization_slug/workspaces", UI.WorkspaceManagementLive, :index live "/o/:organization_slug/workspaces/new", UI.WorkspaceManagementLive, :new + live "/o/:organization_slug/audit-log", UI.AuditLogLive, :index live "/o/:organization_slug/members", UI.OrganizationLive, :members live "/o/:organization_slug/settings", UI.OrganizationLive, :settings live "/w/:organization_slug/:workspace_slug/members", UI.WorkspaceLive, :members diff --git a/test/textbin/accounts_test.exs b/test/textbin/accounts_test.exs index f6ac15d..dd2c2d1 100644 --- a/test/textbin/accounts_test.exs +++ b/test/textbin/accounts_test.exs @@ -570,7 +570,14 @@ defmodule Textbin.AccountsTest do assert %Paste{created_by_user_id: nil, data: "team data remains"} = Repo.get(Paste, team_paste.id) - assert %AuditEvent{actor_user_id: actor_user_id} = + assert %AuditEvent{ + actor_user_id: actor_user_id, + metadata: %{ + "actor_email" => actor_email, + "target_email" => target_email, + "reason" => "account_deleted" + } + } = Repo.get_by!(AuditEvent, organization_id: organization.id, action: "organization.membership.removed", @@ -578,6 +585,8 @@ defmodule Textbin.AccountsTest do ) assert actor_user_id == owner.id + assert actor_email == owner.email + assert target_email == owner.email end test "requires transfer when the user is the final owner of a team workspace" do diff --git a/test/textbin/organizations_test.exs b/test/textbin/organizations_test.exs index 20d93a1..7c7f4e4 100644 --- a/test/textbin/organizations_test.exs +++ b/test/textbin/organizations_test.exs @@ -1060,7 +1060,13 @@ defmodule Textbin.OrganizationsTest do assert event = Enum.find(events, &(&1.action == "organization.name_changed")) assert event.actor_user_id == context.owner.id - assert event.metadata == %{"old" => "Original name", "new" => "New name"} + + assert event.metadata == %{ + "actor_email" => context.owner.email, + "target_name" => "New name", + "old" => "Original name", + "new" => "New name" + } end test "rejects invalid names", context do diff --git a/test/textbin_web/controllers/apiv1/organization_controller_test.exs b/test/textbin_web/controllers/apiv1/organization_controller_test.exs index ddf9c51..8912586 100644 --- a/test/textbin_web/controllers/apiv1/organization_controller_test.exs +++ b/test/textbin_web/controllers/apiv1/organization_controller_test.exs @@ -169,8 +169,7 @@ defmodule TextbinWeb.ApiV1.OrganizationControllerTest do now = DateTime.utc_now() - Repo.insert_all( - AuditEvent, + inserted_events = for index <- 1..105 do %{ id: Ecto.UUID.generate(), @@ -183,7 +182,8 @@ defmodule TextbinWeb.ApiV1.OrganizationControllerTest do inserted_at: now } end - ) + + Repo.insert_all(AuditEvent, inserted_events) bounded_conn = get( @@ -196,6 +196,28 @@ defmodule TextbinWeb.ApiV1.OrganizationControllerTest do assert length(bounded_events) == 100 assert is_binary(bounded_cursor) + + boundary_conn = + get( + context.conn, + ~p"/api/v1/organizations/#{context.organization.id}/audit-events?limit=100&cursor=#{bounded_cursor}" + ) + + assert %{"data" => boundary_events} = json_response(boundary_conn, 200) + assert length(boundary_events) >= 5 + + assert MapSet.disjoint?( + MapSet.new(bounded_events, & &1["id"]), + MapSet.new(boundary_events, & &1["id"]) + ) + + paged_test_event_ids = + (bounded_events ++ boundary_events) + |> Enum.filter(&String.starts_with?(&1["action"], "test.event.")) + |> Enum.map(& &1["id"]) + + assert paged_test_event_ids == + inserted_events |> Enum.map(& &1.id) |> Enum.sort(:desc) end test "requires an API token" do diff --git a/test/textbin_web/live/ui/audit_log_live_test.exs b/test/textbin_web/live/ui/audit_log_live_test.exs new file mode 100644 index 0000000..90f9f23 --- /dev/null +++ b/test/textbin_web/live/ui/audit_log_live_test.exs @@ -0,0 +1,270 @@ +defmodule TextbinWeb.UI.AuditLogLiveTest do + use TextbinWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Textbin.AccountsFixtures + + alias Textbin.Accounts.User + alias Textbin.Organizations + alias Textbin.Organizations.{AuditEvent, OrganizationMembership} + alias Textbin.Repo + + setup %{conn: conn} do + owner = user_fixture() + member = user_fixture() + owner_scope = user_scope_fixture(owner) + + {:ok, organization} = + Organizations.create_organization(owner_scope, %{ + name: "Audit Team", + slug: "audit-team-#{System.unique_integer([:positive])}" + }) + + {:ok, memberships} = + Organizations.add_organization_member(owner_scope, organization, member) + + {:ok, workspace} = + Organizations.create_workspace(owner_scope, organization, %{ + name: "Security", + slug: "security", + visibility: "private" + }) + + %{ + conn: log_in_user(conn, owner), + owner: owner, + member: member, + member_membership: memberships.organization, + owner_scope: owner_scope, + organization: organization, + workspace: workspace + } + end + + test "requires authentication", context do + assert {:error, {:redirect, %{to: path}}} = + live(build_conn(), audit_log_path(context.organization)) + + assert path == ~p"/users/log-in" + end + + test "owners review human-readable activity with actor and target context", context do + {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) + + assert has_element?(view, "#organization-audit-log-page") + assert has_element?(view, "#audit-events") + assert has_element?(view, "#audit-events article", "Workspace created") + assert has_element?(view, "#audit-events article", "Created a private workspace.") + assert has_element?(view, "#audit-events article .break-all", context.owner.email) + + assert has_element?( + view, + "#organization-menu a[href='#{audit_log_path(context.organization)}'][aria-current='page']", + "Audit log" + ) + + assert has_element?( + view, + "#mobile-more-navigation a[href='#{audit_log_path(context.organization)}'][aria-current='page']", + "Audit log" + ) + + assert has_element?(view, "#mobile-navigation-more[aria-current='page']") + end + + test "keeps actor, target, and workspace labels from the time of the event", context do + actor_email = context.owner.email + target_email = context.member.email + + assert {:ok, _membership} = + Organizations.add_workspace_member( + context.owner_scope, + context.workspace, + context.member + ) + + Repo.update!(User.email_changeset(context.owner, %{email: unique_user_email()})) + Repo.update!(User.email_changeset(context.member, %{email: unique_user_email()})) + + {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) + + assert has_element?(view, "#audit-events article", actor_email) + assert has_element?(view, "#audit-events article", target_email) + + assert has_element?( + view, + "#audit-events article", + "Granted member access to #{target_email} in “#{context.workspace.name}”." + ) + end + + test "uses an immutable ID fallback for events without snapshot labels", context do + event = + Repo.insert!(%AuditEvent{ + organization_id: context.organization.id, + actor_user_id: context.owner.id, + action: "legacy.event", + target_type: "workspace", + target_id: context.workspace.id, + metadata: %{} + }) + + current_email = unique_user_email() + Repo.update!(User.email_changeset(context.owner, %{email: current_email})) + + {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) + selector = "#audit-events article[id$='#{event.id}']" + + assert has_element?(view, selector, "User · #{String.slice(context.owner.id, 0, 8)}") + refute has_element?(view, selector, current_email) + end + + test "admins cannot access or see owner-only audit navigation", context do + assert {:ok, _membership} = + Organizations.change_organization_member_role( + context.owner_scope, + context.member_membership, + "admin" + ) + + conn = log_in_user(build_conn(), context.member) + {:ok, overview, _html} = live(conn, organization_path(context.organization)) + + refute has_element?( + overview, + "#organization-menu a[href='#{audit_log_path(context.organization)}']" + ) + + refute has_element?( + overview, + "#mobile-more-navigation a[href='#{audit_log_path(context.organization)}']" + ) + + assert {:error, {:live_redirect, %{to: path}}} = + live(conn, audit_log_path(context.organization)) + + assert path == organization_path(context.organization) + end + + test "loads older activity using the server-issued cursor", context do + for index <- 1..30 do + {:ok, _workspace} = + Organizations.create_workspace(context.owner_scope, context.organization, %{ + name: "Audit workspace #{index}", + slug: "audit-workspace-#{index}", + visibility: "open" + }) + end + + assert {:ok, all_events} = + Organizations.list_audit_events(context.owner_scope, context.organization) + + {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) + + assert element_count(view, "#audit-events article") == 25 + assert has_element?(view, "#audit-events article", "Created an open workspace.") + assert has_element?(view, "#load-more-audit-events") + + view + |> element("#load-more-audit-events") + |> render_click() + + assert element_count(view, "#audit-events article") == length(all_events) + refute has_element?(view, "#load-more-audit-events") + end + + test "loading another page reauthorizes an owner after demotion", context do + for index <- 1..25 do + {:ok, _workspace} = + Organizations.create_workspace(context.owner_scope, context.organization, %{ + name: "Authorization event #{index}", + slug: "authorization-event-#{index}", + visibility: "open" + }) + end + + assert {:ok, _membership} = + Organizations.change_organization_member_role( + context.owner_scope, + context.member_membership, + "owner" + ) + + {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) + assert has_element?(view, "#load-more-audit-events") + + first_owner_membership = + Repo.get_by!(OrganizationMembership, + organization_id: context.organization.id, + user_id: context.owner.id + ) + + assert {:ok, _membership} = + Organizations.change_organization_member_role( + user_scope_fixture(context.member), + first_owner_membership, + "member" + ) + + view + |> element("#load-more-audit-events") + |> render_click() + + assert_redirect(view, organization_path(context.organization)) + end + + test "loading another page conceals events after organization access is revoked", context do + for index <- 1..25 do + {:ok, _workspace} = + Organizations.create_workspace(context.owner_scope, context.organization, %{ + name: "Revocation event #{index}", + slug: "revocation-event-#{index}", + visibility: "open" + }) + end + + assert {:ok, _membership} = + Organizations.change_organization_member_role( + context.owner_scope, + context.member_membership, + "owner" + ) + + {:ok, view, _html} = live(context.conn, audit_log_path(context.organization)) + assert has_element?(view, "#load-more-audit-events") + + first_owner_membership = + Repo.get_by!(OrganizationMembership, + organization_id: context.organization.id, + user_id: context.owner.id + ) + + Repo.delete!(first_owner_membership) + + view + |> element("#load-more-audit-events") + |> render_click() + + assert_redirect(view, ~p"/orgs") + end + + test "conceals audit logs for organizations the user has not joined", context do + outsider_conn = build_conn() |> log_in_user(user_fixture()) + + assert_raise Ecto.NoResultsError, fn -> + live(outsider_conn, audit_log_path(context.organization)) + end + end + + defp element_count(view, selector) do + view + |> render() + |> LazyHTML.from_fragment() + |> LazyHTML.query(selector) + |> LazyHTML.to_tree() + |> length() + end + + defp audit_log_path(organization), do: "/o/#{organization.slug}/audit-log" + defp organization_path(organization), do: "/o/#{organization.slug}" +end