Repository: CDCgov/dibbs-query-connector
Pull request #577: mark user management functions as auditable
Author: login: fzhao99
Created: 2025-05-05T13:57:33Z | Merged: 2025-05-06T13:12:48Z

# PULL REQUEST

## Summary
Marks user management / session management functions as auditable, as well as some other improvements (see additional info)

https://github.com/user-attachments/assets/b38ae2d0-b67a-4b94-9def-f623c3631bc3

## Related Issue
Fixes [QUE-194](https://linear.app/skylight-cdc/issue/QUE-194/methods-handing-user-session-management-are-marked-as-auditable)

## Additional info
- Extends the camel case decorator to handle nested objects and stringified DB stores, as well as arrays and circular references
- Adds maps for the session management functions. Will add some tests for these in an upcoming PR. Reorganize the typing a little bit to be more generic
- Refactors the header to take session information as a prop to hide / render buttons correctly when the user logs in or out
- Puts a wrapper around the session management (sign in / out) functions that allow things to be auditable
- Refactors the audit logic to expose a success callback method that the tests await on to make them less prone to race conditions

## Checklist

- [x] Descriptive Pull Request title
- [x] Link to relevant issues
- [ ] Provide necessary context for design reviewers
- [ ] Ensure test coverage is above agreed upon threshold
- [ ] Update documentation



Diff:
diff --git a/src/app/(pages)/auditLogs/components/auditLogMaps.ts b/src/app/(pages)/auditLogs/components/auditLogMaps.ts
index aa2e58c0e..f4f96cb24 100644
--- a/src/app/(pages)/auditLogs/components/auditLogMaps.ts
+++ b/src/app/(pages)/auditLogs/components/auditLogMaps.ts
@@ -1,22 +1,16 @@
 import { getAllUsers } from "@/app/backend/user-management";
+import { Profile, Session } from "next-auth";
 
 type AuditLogActionTypeFormatter = (
   logEntry: Record<string, unknown>,
 ) => string;
 
-type RequestPayload = {
-  queryName?: string;
-  firstName?: string;
-  lastName?: string;
-  [key: string]: unknown;
-};
-
 type auditLogActionTypeMapping = {
   label: string;
   format: AuditLogActionTypeFormatter;
 };
 
-function parseRequest(log: Record<string, unknown>): RequestPayload {
+function parseRequest(log: Record<string, unknown>) {
   const msg = log.auditMessage;
   let raw: unknown;
 
@@ -26,16 +20,6 @@ function parseRequest(log: Record<string, unknown>): RequestPayload {
     raw = msg;
   }
 
-  if (
-    raw &&
-    typeof raw === "object" &&
-    !Array.isArray(raw) &&
-    Object.keys(raw).length === 1
-  ) {
-    const onlyKey = Object.keys(raw)[0];
-    raw = (raw as Record<string, unknown>)[onlyKey];
-  }
-
   if (typeof raw === "string") {
     try {
       raw = JSON.parse(raw);
@@ -93,7 +77,10 @@ export const auditLogActionTypeMap: Record<string, auditLogActionTypeMapping> =
     makePatientDiscoveryRequest: {
       label: "Patient discovery query",
       format: (log) => {
-        const request = parseRequest(log);
+        const request = parseRequest(log) as {
+          firstName: string;
+          lastName: string;
+        };
         const fullName = resolveFullName(
           request.firstName,
           request.lastName,
@@ -105,28 +92,37 @@ export const auditLogActionTypeMap: Record<string, auditLogActionTypeMapping> =
     deleteFhirServer: {
       label: "Delete FHIR server",
       format: (log) => {
-        const request = parseRequest(log);
+        const request = parseRequest(log) as {
+          name: string;
+        };
         return `Deleted FHIR server ${request.name ?? ""}`.trim();
       },
     },
     insertFhirServer: {
       label: "Insert FHIR server",
       format: (log) => {
-        const request = parseRequest(log);
+        const request = parseRequest(log) as {
+          name: string;
+        };
         return `Inserted FHIR server ${request.name ?? ""}`.trim();
       },
     },
     updateFhirServer: {
       label: "Update FHIR server",
       format: (log) => {
-        const request = parseRequest(log);
+        const request = parseRequest(log) as {
+          name: string;
+        };
         return `Updated FHIR server ${request.name ?? ""}`.trim();
       },
     },
     insertValueSet: {
       label: "Insert value set",
       format: (log) => {
-        const request = parseRequest(log);
+        const request = parseRequest(log) as {
+          valueSetName: string;
+          valueSetId: string;
+        };
         return `Inserted value set ${
           request.valueSetName ?? request.valueSetId ?? ""
         }`.trim();
@@ -134,7 +130,7 @@ export const auditLogActionTypeMap: Record<string, auditLogActionTypeMapping> =
     },
     executeCategoryUpdates: {
       label: "Execute category updates",
-      format: (log) => {
+      format: () => {
         return `Executed category updates`;
       },
     },
@@ -148,6 +144,115 @@ export const auditLogActionTypeMap: Record<string, auditLogActionTypeMapping> =
         return `Inserted ${length} database rows for ${table} table`;
       },
     },
+    auditableSignOut: {
+      label: "User sign out",
+      format: (log) => {
+        type SignOutJsonBlob = {
+          sessionParams: {
+            session: Session;
+          };
+        };
+
+        const request = parseRequest(log) as SignOutJsonBlob;
+        const userId = request?.sessionParams.session?.user?.id;
+        return `Sign out of user with ID ${userId}`;
+      },
+    },
+    auditableSignIn: {
+      label: "User sign in",
+      format: (log) => {
+        const request = parseRequest(log) as { profile: Profile };
+        const username = request?.profile.preferredUsername;
+        return `Sign in of user with username ${username}`;
+      },
+    },
+    addUserIfNotExists: {
+      label: "User configured",
+      format: (log) => {
+        const request = parseRequest(log);
+        const username = (Object.values(request)[0] as { username: string })
+          ?.username;
+        return `User configured with username ${username}`;
+      },
+    },
+    updateUserDetails: {
+      label: "User details updated",
+      format: (log) => {
+        const request = parseRequest(log);
+        const userId = Object.values(request)[0];
+        return `User details updated for user with ID ${userId}`;
+      },
+    },
+    updateUserRole: {
+      label: "User role updated",
+      format: (log) => {
+        const request = parseRequest(log);
+        const userId = Object.values(request)[0];
+        const newRole = Object.values(request)[1];
+        return `User role of user with userId ${userId} updated to new role ${newRole}`;
+      },
+    },
+    createUserGroup: {
+      label: "User group created",
+      format: (log) => {
+        const request = parseRequest(log);
+        const groupName = Object.values(request)[0];
+        return `Group created with name ${groupName}`;
+      },
+    },
+    updateUserGroup: {
+      label: "User group updated",
+      format: (log) => {
+        const request = parseRequest(log) as Profile;
+        const id = Object.values(request)[0];
+        const newName = Object.values(request)[1];
+        return `Group with ID ${id} updated with new name ${newName}`;
+      },
+    },
+    deleteUserGroup: {
+      label: "User group deleted",
+      format: (log) => {
+        const request = parseRequest(log);
+        const id = Object.values(request)[0];
+        return `Group with ID ${id} deleted`;
+      },
+    },
+    addUsersToGroup: {
+      label: "Users added to user group",
+      format: (log) => {
+        const request = parseRequest(log);
+        const groupId = Object.values(request)[0];
+        const userId = (Object.values(request)[1] as string[]).join(", ");
+        return `User with ID ${userId} added to group with ID ${groupId} `;
+      },
+    },
+    removeUsersFromGroup: {
+      label: "Users removed from user group",
+      format: (log) => {
+        const request = parseRequest(log);
+        const groupId = Object.values(request)[0];
+        const userId = Object.values(request)[1];
+        return `User with ID ${userId} removed to group with ID ${groupId} `;
+      },
+    },
+    addQueriesToGroup: {
+      label: "Queries added to user group",
+      format: (log) => {
+        const request = parseRequest(log);
+        const groupId = Object.values(request)[0];
+        const queryId = Object.values(request)[1];
+        return `Query with ID ${queryId} added to group ${groupId}`;
+      },
+    },
+    removeQueriesFromGroup: {
+      label: "Queries removed from user group",
+      format: (log) => {
+        const request = parseRequest(log);
+        const groupId = Object.values(request)[0];
+        const queryId = Object.values(request)[1];
+        return `Query with ID ${queryId} removed from group ${groupId}`;
+      },
+    },
     // Add more as needed
   };
 
diff --git a/src/app/(pages)/landingPage/landingPage.tsx b/src/app/(pages)/landingPage/landingPage.tsx
index aa732b3c9..12389282b 100644
--- a/src/app/(pages)/landingPage/landingPage.tsx
+++ b/src/app/(pages)/landingPage/landingPage.tsx
@@ -7,9 +7,9 @@ import {
 } from "@trussworks/react-uswds";
 import Image from "next/image";
 import styles from "./landingPage.module.scss";
-import { signIn } from "next-auth/react";
 import { useEffect, useState } from "react";
 import { useRouter } from "next/navigation";
+import { signIn } from "@/app/backend/session-management";
 
 interface LandingPageProps {
   isLoggedIn: boolean;
@@ -36,8 +36,8 @@ const LandingPage: React.FC<LandingPageProps> = ({ isLoggedIn }) => {
     }
   }, [isLoggedIn]);
 
-  const handleClick = () => {
-    signIn("keycloak", { redirectTo: "/query" });
+  const handleClick = async () => {
+    await signIn("keycloak", { redirectTo: "/query" });
   };
 
   return (
diff --git a/src/app/backend/auditLogs/decorator.ts b/src/app/backend/auditLogs/decorator.ts
index 62e1322a6..5bc103fe6 100644
--- a/src/app/backend/auditLogs/decorator.ts
+++ b/src/app/backend/auditLogs/decorator.ts
@@ -1,6 +1,6 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 import { getDbClient } from "../dbClient";
-import { generateAuditValues } from "./lib";
+import { generateAuditSuccessMessage, generateAuditValues } from "./lib";
 
 export const AUDIT_LOG_MAX_RETRIES = 3;
 /**
@@ -35,20 +35,28 @@ export function auditable(
     const attemptWrite = (retryCounter: number): Promise<void> => {
       return generateAuditValues(key, argLabels, args)
         .then(
-          async ([author, methodName, auditMessage, timestamp, checksum]) => {
+          async ([
+            author,
+            methodName,
+            auditMessage,
+            timestamp,
+            auditChecksum,
+          ]) => {
             const result = await dbConnection.query(insertQuery, [
               author,
               methodName,
               auditMessage,
               timestamp,
-              checksum,
+              auditChecksum,
             ]);
 
             const insertedRow = result.rows[0];
-
-            console.info(
-              `${insertedRow.action_type} audit action with id ${insertedRow.id} and checksum ${insertedRow.audit_checksum} added to audit table`,
+            const successMessage = generateAuditSuccessMessage(
+              insertedRow.action_type,
+              insertedRow.id,
+              auditChecksum as string,
             );
+            console.info(successMessage);
             return;
           },
         )
diff --git a/src/app/backend/auditLogs/lib.ts b/src/app/backend/auditLogs/lib.ts
index c46ed7161..1b5d7ec7c 100644
--- a/src/app/backend/auditLogs/lib.ts
+++ b/src/app/backend/auditLogs/lib.ts
@@ -5,6 +5,21 @@ import crypto from "crypto";
 //  to allow us to spy on them when calling @auditable functions
 // Relevant thread: https://stackoverflow.com/questions/61111423/expected-spyon-function-to-be-called-jest
 
+/**
+ * Util function generate the success string after an audit write
+ * @param actionType - the type of the audit information
+ * @param id - id of the audit row
+ * @param  checksum - the checksum generated by the audit contents
+ * @returns Relevant info to track down the audit success message
+ */
+export function generateAuditSuccessMessage(
+  actionType: string,
+  id: string,
+  checksum: string,
+) {
+  return `${actionType} audit action with id ${id} and checksum ${checksum} added to audit table`;
+}
+
 /**
  * Helper function that formats method input into the values needed by the audit
  * log table
@@ -19,7 +34,7 @@ export async function generateAuditValues(
   args: unknown[],
 ) {
   const session = await auth();
-  const author = session?.user?.username || "Unknown";
+  const author = session?.user?.username || "No session information";
   const timestamp = new Date().toISOString();
   const auditContents = generateAuditMessage(argLabels, args);
   const auditChecksum = generateAuditChecksum(author, auditContents, timestamp);
diff --git a/src/app/backend/dbServices/decorators.ts b/src/app/backend/dbServices/decorators.ts
index 4032a7a41..30ef5dd60 100644
--- a/src/app/backend/dbServices/decorators.ts
+++ b/src/app/backend/dbServices/decorators.ts
@@ -1,7 +1,7 @@
 import { adminAccessCheck, superAdminAccessCheck } from "@/app/utils/auth";
 import { getDbClient } from "../dbClient";
 import { QueryResult } from "pg";
-import { translateSnakeStringToCamelCase } from "./util";
+import { translateNestedObjectKeysIntoCamelCase } from "./util";
 
 /**
  * Annotation to make a db query into a transaction. Requires all return branches
@@ -142,11 +142,7 @@ export function camelCaseDbColumnNames<T extends Record<string, unknown>>(
       }
 
       result.rows = result.rows.map((v) => {
-        const val: Record<string, unknown> = {};
-        Object.entries(v).forEach(([k, v]) => {
-          val[translateSnakeStringToCamelCase(k)] = v;
-        });
-
+        const val = translateNestedObjectKeysIntoCamelCase(v as object);
         return val as T;
       });
 
diff --git a/src/app/backend/dbServices/util.ts b/src/app/backend/dbServices/util.ts
index 016e684a8..4d0f08a9f 100644
--- a/src/app/backend/dbServices/util.ts
+++ b/src/app/backend/dbServices/util.ts
@@ -29,3 +29,65 @@ export function translateSnakeStringToCamelCase(str: string) {
     return letter.toUpperCase();
   });
 }
+
+/**
+ * Utility function that camel cases the keys of objects, handling nested objects
+ * as well
+ * @param obj - the object to format
+ * @param seen - Weekset to protect against circular references
+ * @returns An object whose keys are camel cased
+ */
+export function translateNestedObjectKeysIntoCamelCase(
+  obj: object,
+  seen: WeakSet<object> = new WeakSet(),
+): object {
+  if (obj === null || typeof obj !== "object") {
+    console.log("in term: ", obj);
+    return obj;
+  }
+
+  // guard against arrays
+  if (Array.isArray(obj)) {
+    return obj.map((item) =>
+      typeof item === "object" && item !== null
+        ? translateNestedObjectKeysIntoCamelCase(item, seen)
+        : item,
+    );
+  }
+
+  // Prevent circular references
+  if (seen.has(obj)) {
+    return obj;
+  }
+  seen.add(obj);
+
+  const formatedObj: Record<string, unknown> = {};
+  Object.entries(obj).forEach(([k, v]) => {
+    let valueToSet = v;
+
+    if (typeof v === "string") {
+      try {
+        const parsedObject = JSON.parse(v);
+        if (parsedObject && typeof parsedObject === "object") {
+          valueToSet = JSON.stringify(
+            translateNestedObjectKeysIntoCamelCase(parsedObject),
+          );
+        }
+      } catch {}
+    } else if (isPlainObject(v)) {
+      valueToSet = translateNestedObjectKeysIntoCamelCase(v, seen);
+    }
+
+    formatedObj[translateSnakeStringToCamelCase(k)] = valueToSet;
+  });
+
+  return formatedObj;
+}
+
+function isPlainObject(obj: unknown): obj is Record<string, unknown> {
+  return (
+    typeof obj === "object" &&
+    obj !== null &&
+    Object.getPrototypeOf(obj) === Object.prototype
+  );
+}
diff --git a/src/app/backend/session-management.ts b/src/app/backend/session-management.ts
new file mode 100644
index 000000000..0ebc35734
--- /dev/null
+++ b/src/app/backend/session-management.ts
@@ -0,0 +1,49 @@
+"use server";
+import { signIn as nextAuthSignIn, signOut as nextAuthSignOut } from "@/auth";
+import { auditable } from "./auditLogs/decorator";
+import { auth } from "@/auth";
+import type { Profile, Session } from "next-auth";
+
+// Define types from the original functions
+type NextAuthSignInParams = Parameters<typeof nextAuthSignIn>;
+type NextAuthSignOutParams = Parameters<typeof nextAuthSignOut>;
+
+class SessionManagementServiceAuditables {
+  @auditable
+  protected static async auditableSignOut(sessionParams: {
+    args: NextAuthSignOutParams;
+    session: Session | null;
+  }) {
+    return await nextAuthSignOut(...sessionParams.args);
+  }
+
+  @auditable
+  static async auditableSignIn(profile: Profile | undefined) {
+    // just return since the log is happening within the decorator
+    return profile;
+  }
+}
+
+class SessionManagementService extends SessionManagementServiceAuditables {
+  static async signOut(...args: NextAuthSignOutParams) {
+    const session = await auth();
+    return super.auditableSignOut({
+      args: args as NextAuthSignOutParams,
+      session,
+    });
+  }
+
+  static async signIn(...args: NextAuthSignInParams) {
+    return await nextAuthSignIn(...args);
+  }
+}
+
+export const signOut = SessionManagementService.signOut;
+export const signIn = SessionManagementService.signIn;
+
+// this is exported and then called within the auth.ts file directly in the sign in callback
+// since the information we're interested in logging isn't available until NextAuth
+// redirects back to the application. We tried the more straightforward option
+// of awaiting / logging it here to follow the pattern in the rest of the application,
+// but couldn't get it to work.
+export const logSignInToAuditTable = SessionManagementService.auditableSignIn;
diff --git a/src/app/backend/user-management.ts b/src/app/backend/user-management.ts
index 4a3c11b20..5f2c090f4 100644
--- a/src/app/backend/user-management.ts
+++ b/src/app/backend/user-management.ts
@@ -10,6 +10,7 @@ import {
 import { QCResponse } from "../models/responses/collections";
 import dbService from "./dbServices/db-service";
 import { adminRequired, superAdminRequired } from "./dbServices/decorators";
+import { auditable } from "./auditLogs/decorator";
 
 class UserManagementService {
   /**
@@ -44,6 +45,7 @@ class UserManagementService {
    * @param userToken.lastName - The last name from the JWT token.
    * @returns The newly added user or an empty result if already exists.
    */
+  @auditable
   static async addUserIfNotExists(userToken: {
     id: string;
     username: string;
@@ -105,6 +107,7 @@ class UserManagementService {
    * @param updated_role - The role from the JWT token.
    * @returns The newly added user or an empty result if already exists.
    */
+  @auditable
   static async updateUserDetails(
     userId: string,
     updated_userName: string,
@@ -195,6 +198,7 @@ class UserManagementService {
    * @returns The updated user record or an error if the update fails.
    */
   @superAdminRequired
+  @auditable
   static async updateUserRole(
     userId: string,
     newRole: UserRole,
diff --git a/src/app/backend/usergroup-management.ts b/src/app/backend/usergroup-management.ts
index 94ca1c505..fc7bc4e00 100644
--- a/src/app/backend/usergroup-management.ts
+++ b/src/app/backend/usergroup-management.ts
@@ -1,16 +1,14 @@
 "use server";
 import { User, UserGroup, UserGroupMembership } from "../models/entities/users";
-import {
-  getAllUsersWithSingleGroupStatus,
-  getSingleUserWithGroupMemberships,
-} from "./user-management";
+import { getSingleUserWithGroupMemberships } from "./user-management";
 
 import { QCResponse } from "../models/responses/collections";
 import { CustomUserQuery } from "../models/entities/query";
 import { QueryResult } from "pg";
 import { getQueryById } from "./query-building";
 import dbService from "./dbServices/db-service";
-import { adminRequired, transaction } from "./dbServices/decorators";
+import { adminRequired } from "./dbServices/decorators";
+import { auditable } from "./auditLogs/decorator";
 
 class UserGroupManagementService {
   /**
@@ -19,6 +17,7 @@ class UserGroupManagementService {
    * @returns The created user group or an error message if it already exists.
    */
   @adminRequired
+  @auditable
   static async createUserGroup(
     groupName: string,
   ): Promise<QCResponse<UserGroup>> {
@@ -29,9 +28,6 @@ class UserGroupManagementService {
         existingGroups.items?.some((group) => group.name === groupName) ??
         false;
 
-      console.log(existingGroups);
-      console.log(groupExists);
-
       if (groupExists) {
         throw new Error(`User group '${groupName}' already exists.`);
       }
@@ -64,6 +60,7 @@ class UserGroupManagementService {
    * @returns The updated user group or an error if the update fails.
    */
   @adminRequired
+  @auditable
   static async updateUserGroup(
     id: string,
     newName: string,
@@ -120,6 +117,7 @@ class UserGroupManagementService {
    * @returns The deleted user group or an error if the deletion fails.
    */
   @adminRequired
+  @auditable
   static async deleteUserGroup(id: string): Promise<UserGroup | string> {
     try {
       await dbService.query(
@@ -161,6 +159,7 @@ class UserGroupManagementService {
    * @returns The user IDs of the users added to the group.
    */
   @adminRequired
+  @auditable
   static async addUsersToGroup(
     groupId: string,
     userIds: string[],
@@ -206,6 +205,7 @@ class UserGroupManagementService {
    * @param userIds - The unique identifier(s) of the users to add.
    * @returns The user IDs of the users added to the group.
    */
+  @auditable
   static async removeUsersFromGroup(
     groupId: string,
     userIds: string[],
@@ -241,59 +241,6 @@ class UserGroupManagementService {
     }
   }
 
-  /**
-   * Saves the user group membership for a specific user group.
-   * @param groupId - The unique identifier of the user group.
-   * @param selectedUsers - The unique identifiers of the users to add to the group.
-   * @returns A list of user group memberships for the specified group.
-   */
-  @transaction
-  @adminRequired
-  static async saveUserGroupMembership(
-    groupId: string,
-    selectedUsers: string[],
-  ): Promise<{ success: boolean; users: User[] }> {
-    try {
-      const existingMemberships =
-        await getAllUsersWithSingleGroupStatus(groupId);
-      const existingUserIds = new Set(
-        existingMemberships.flatMap(
-          (user) =>
-            user.userGroupMemberships
-              ?.filter((m) => m.isMember)
-              .map(() => user.id) || [],
-        ),
-      );
-
-      const usersToAdd = selectedUsers.filter((id) => !existingUserIds.has(id));
-      const usersToRemove = existingMemberships.flatMap(
-        (user) =>
-          user.userGroupMemberships
-            ?.filter((m) => m.isMember && !selectedUsers.includes(user.id))
-            .map(() => user.id) || [],
-      );
-
-      if (usersToRemove.length > 0)
-        await UserGroupManagementService.removeUsersFromGroup(
-          groupId,
-          usersToRemove,
-        );
-      if (usersToAdd.length > 0) {
-        await UserGroupManagementService.addUsersToGroup(groupId, usersToAdd);
-      }
-
-      const updatedUsers = await getAllUsersWithSingleGroupStatus(groupId);
-      return { success: true, users: updatedUsers };
-    } catch (error) {
-      console.error(
-        "Save for user group membership failed for group ",
-        groupId,
-        error,
-      );
-      return { success: false, users: [] };
-    }
-  }
-
   /**
    * Retrieves all registered user groups in Query Connector along with member and query counts.
    * @returns A list of user groups registered in the query connector.
@@ -451,6 +398,7 @@ class UserGroupManagementService {
    * @param queryIds - The unique identifier of the query to add.
    * @returns The IDs of the queries added to the group.
    */
+  @auditable
   static async addQueriesToGroup(
     groupId: string,
     queryIds: string[],
@@ -506,6 +454,7 @@ class UserGroupManagementService {
    * @param queryIds - The unique identifiers of the query to remove.
    * @returns The query with updated groupAssignment data
    */
+  @auditable
   static async removeQueriesFromGroup(
     groupId: string,
     queryIds: string[],
@@ -625,8 +574,6 @@ export const removeQueriesFromGroup =
   UserGroupManagementService.removeQueriesFromGroup;
 export const removeUsersFromGroup =
   UserGroupManagementService.removeUsersFromGroup;
-export const saveUserGroupMembership =
-  UserGroupManagementService.saveUserGroupMembership;
 export const getAllUserGroups = UserGroupManagementService.getAllUserGroups;
 export const getUserGroupById = UserGroupManagementService.getUserGroupById;
 export const getAllGroupQueries = UserGroupManagementService.getAllGroupQueries;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 79682e99f..488779a23 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,7 +6,6 @@ import DataProvider from "./shared/DataProvider";
 import { Metadata } from "next";
 import Page from "./ui/components/page/page";
 import { auth } from "@/auth";
-import { isAuthDisabledServerCheck } from "./utils/auth";
 import SessionTimeout, {
   IDLE_TIMEOUT_MSEC,
   PROMPT_TIMEOUT_MSEC,
@@ -40,7 +39,7 @@ export default async function RootLayout({
               idleTimeMsec={IDLE_TIMEOUT_MSEC}
               promptTimeMsec={PROMPT_TIMEOUT_MSEC}
             />
-            <Header authDisabled={isAuthDisabledServerCheck()} />
+            <Header session={session} />
             <Page showSiteAlert={process.env.DEMO_MODE === "true"}>
               {children}
             </Page>
diff --git a/src/app/shared/database-service.ts b/src/app/shared/database-service.ts
index 76a6558d9..513795013 100644
--- a/src/app/shared/database-service.ts
+++ b/src/app/shared/database-service.ts
@@ -571,19 +571,6 @@ class DatabaseService {
     return match ? match[1] : systemURL;
   }
 
-  private static logRejectedPromiseReasons<T>(
-    resultsArray: PromiseSettledResult<T>[],
-    errorMessageString: string,
-  ) {
-    return resultsArray
-      .filter((r): r is PromiseRejectedResult => r.status === "rejected")
-      .map((r) => {
-        console.error(errorMessageString);
-        console.error(r.reason);
-        return r.reason;
-      });
-  }
-
   /**
    * Helper function that execute the category data updates for inserted conditions.
    * @param vs - The ValueSet in of the shape of our internal data model to insert
diff --git a/src/app/tests/integration/audit-logging.test.ts b/src/app/tests/integration/audit-logging.test.ts
index 66fa1baf3..86231d399 100644
--- a/src/app/tests/integration/audit-logging.test.ts
+++ b/src/app/tests/integration/audit-logging.test.ts
@@ -1,6 +1,4 @@
 import { auth } from "@/auth";
-import { Bundle, BundleEntry, Patient } from "fhir/r4";
-import { readJsonFile } from "../shared_utils/readJsonFile";
 import { hyperUnluckyPatient, USE_CASE_DETAILS } from "@/app/shared/constants";
 import {
   patientDiscoveryQuery,
@@ -19,6 +17,13 @@ import {
 } from "@/app/models/entities/query";
 import dbService from "@/app/backend/dbServices/db-service";
 import { getDbClient } from "@/app/backend/dbClient";
+import {
+  logSignInToAuditTable,
+  signOut,
+} from "@/app/backend/session-management";
+
+import * as AuditableDecorators from "@/app/backend/auditLogs/lib";
+import { waitFor } from "@testing-library/dom";
 const dbClient = getDbClient();
 
 jest.mock("@/app/backend/auditLogs/lib", () => {
@@ -49,15 +54,6 @@ const TEST_USER = {
 };
 (auth as jest.Mock).mockResolvedValue(TEST_USER);
 
-const PatientBundle = readJsonFile("./src/app/tests/assets/BundlePatient.json");
-const PatientResource: Patient | undefined = (
-  (PatientBundle as Bundle).entry as BundleEntry[]
-)[0]?.resource as Patient;
-
-if (!PatientResource || PatientResource.resourceType !== "Patient") {
-  throw new Error("Invalid Patient resource in the test bundle.");
-}
-
 const GET_ALL_AUDIT_ROWS = "SELECT * FROM audit_logs;";
 describe("audit log", () => {
   beforeAll(() => {
@@ -109,9 +105,7 @@ describe("audit log", () => {
     const newAuditRows = await dbService.query(GET_ALL_AUDIT_ROWS);
     const auditEntry = newAuditRows.rows.filter((r) => {
       return (
-        r.author === TEST_USER.user.username &&
-        r.actionType === actionTypeToCheck &&
-        !oldAuditIds.includes(r.id)
+        r.author === TEST_USER.user.username && !oldAuditIds.includes(r.id)
       );
     })[0];
 
@@ -132,26 +126,21 @@ describe("audit log", () => {
       DecoratorUtils,
       "generateAuditValues",
     );
-
     const querySpy = jest.spyOn(dbClient, "query");
-
     // generate errors within the query write AUDIT_LOG_MAX_RETRIES - 1 times
     for (let i = 0; i < AUDIT_LOG_MAX_RETRIES; i++) {
       querySpy.mockImplementationOnce(() => {
         throw new Error("test error");
       });
     }
-
     class MockClass {
       @auditable
       testFunction() {
         return;
       }
     }
-
     const testObj = new MockClass();
     testObj.testFunction();
-
     await new Promise((r) => setTimeout(r, 6000));
     expect(auditGenerationSpy).toHaveBeenCalledTimes(AUDIT_LOG_MAX_RETRIES);
   }, 10000);
@@ -165,26 +154,91 @@ describe("audit log", () => {
       mrn: hyperUnluckyPatient.MRN,
       phone: hyperUnluckyPatient.Phone,
     };
-
     await patientDiscoveryQuery(request);
-
     const result = await dbClient.query(
       "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1",
     );
     const insertedId = result.rows[0].id;
-
     await expect(
       dbClient.query(`UPDATE audit_logs SET author = 'hacker' WHERE id = $1`, [
         insertedId,
       ]),
     ).rejects.toThrow(/UPDATEs to audit_logs are not permitted/i);
-
     await expect(
       dbClient.query(`DELETE FROM audit_logs WHERE id = $1`, [insertedId]),
     ).rejects.toThrow(/DELETEs from audit_logs are not permitted/i);
   });
 });
 
+describe("sign in and out", () => {
+  it("sign out should generate an audit entry", async () => {
+    const auditCompletionSpy = jest.spyOn(
+      AuditableDecorators,
+      "generateAuditSuccessMessage",
+    );
+    const allAuditRows = await dbService.query(GET_ALL_AUDIT_ROWS);
+    const oldAuditIds = allAuditRows.rows.map((r) => r.id);
+
+    await signOut();
+    await waitFor(() => {
+      expect(auditCompletionSpy).toHaveBeenCalled();
+    });
+
+    const actionTypeToCheck = "auditableSignOut";
+    const newAuditRows = await dbService.query(GET_ALL_AUDIT_ROWS);
+    const auditEntry = newAuditRows.rows.filter((r) => {
+      return (
+        r.author === TEST_USER.user.username && !oldAuditIds.includes(r.id)
+      );
+    })[0];
+    const userInfo = JSON.parse(auditEntry?.auditMessage?.sessionParams).session
+      .user;
+
+    expect(auditEntry?.actionType).toBe(actionTypeToCheck);
+    expect(userInfo.username).toBe(TEST_USER.user.username);
+    expect(userInfo.firstName).toBe(TEST_USER.user.firstName);
+    expect(userInfo.lastName).toBe(TEST_USER.user.lastName);
+  });
+
+  it("sign in should generate an audit entry", async () => {
+    const auditCompletionSpy = jest.spyOn(
+      AuditableDecorators,
+      "generateAuditSuccessMessage",
+    );
+
+    const allAuditRows = await dbService.query(GET_ALL_AUDIT_ROWS);
+    const oldAuditIds = allAuditRows.rows.map((r) => r.id);
+
+    await logSignInToAuditTable({
+      preferred_username: TEST_USER.user.username,
+      given_name: TEST_USER.user.firstName,
+      family_name: TEST_USER.user.lastName,
+    });
+    const actionTypeToCheck = "auditableSignIn";
+    await waitFor(() => {
+      expect(auditCompletionSpy).toHaveBeenCalled();
+    });
+
+    const newAuditRows = await dbService.query(GET_ALL_AUDIT_ROWS);
+
+    const auditResults = newAuditRows.rows.filter((r) => {
+      return (
+        r.author === TEST_USER.user.username &&
+        r.actionType === actionTypeToCheck &&
+        !oldAuditIds.includes(r.id)
+      );
+    });
+
+    const auditEntry = auditResults[0];
+    const userInfo = JSON.parse(auditEntry?.auditMessage?.profile);
+
+    expect(auditEntry?.actionType).toBe(actionTypeToCheck);
+    expect(userInfo.preferredUsername).toBe(TEST_USER.user.username);
+    expect(userInfo.givenName).toBe(TEST_USER.user.firstName);
+    expect(userInfo.familyName).toBe(TEST_USER.user.lastName);
+  });
+});
+
 describe("generateAuditChecksum", () => {
   it("produces correct checksum for audit message with stringified request", () => {
     const author = "WorfSonOfMogh";
diff --git a/src/app/tests/integration/usergroup-management.test.ts b/src/app/tests/integration/usergroup-management.test.ts
index dffe838a9..0fa1bd3d6 100644
--- a/src/app/tests/integration/usergroup-management.test.ts
+++ b/src/app/tests/integration/usergroup-management.test.ts
@@ -5,7 +5,6 @@ import {
   getAllGroupQueries,
   removeUsersFromGroup,
   removeQueriesFromGroup,
-  saveUserGroupMembership,
 } from "@/app/backend/usergroup-management";
 import { getAllUsersWithSingleGroupStatus } from "@/app/backend/user-management";
 import { getDbClient } from "@/app/backend/dbClient";
@@ -223,35 +222,6 @@ describe("User Group and Query Membership Tests", () => {
     expect(result.items).toEqual([]);
   });
 
-  /**
-   * Tests saving user group memberships.
-   */
-  test("should correctly update user group memberships", async () => {
-    const selectedUsers = [TEST_USER_1_ID];
-
-    // Add and remove users in one call
-    const { users: updatedUsers } = await saveUserGroupMembership(
-      TEST_GROUP_ID,
-      selectedUsers,
-    );
-
-    // Verify membership contains only selected users
-    expect(
-      updatedUsers.some(
-        (user) =>
-          user.id === TEST_USER_1_ID &&
-          user.userGroupMemberships?.some((m) => m.isMember),
-      ),
-    ).toBe(true);
-    expect(
-      updatedUsers.some(
-        (user) =>
-          user.id === TEST_USER_2_ID &&
-          user.userGroupMemberships?.some((m) => m.isMember),
-      ),
-    ).toBe(false);
-  });
-
   /**
    * Tests adding queries to a user group.
    */
diff --git a/src/app/ui/components/header/header.tsx b/src/app/ui/components/header/header.tsx
index f78494105..0298434d5 100644
--- a/src/app/ui/components/header/header.tsx
+++ b/src/app/ui/components/header/header.tsx
@@ -1,7 +1,6 @@
 "use client";
 
 import React, { useContext, useEffect, useRef, useState } from "react";
-import { useSession, signOut } from "next-auth/react";
 import { Icon } from "@trussworks/react-uswds";
 import styles from "./header.module.scss";
 import { metadata } from "@/app/shared/constants";
@@ -12,26 +11,28 @@ import { getPagesInSettingsMenu, PAGES } from "@/app/shared/page-routes";
 import { UserRole } from "@/app/models/entities/users";
 import { isAuthDisabledClientCheck } from "@/app/utils/auth";
 import { DataContext } from "@/app/shared/DataProvider";
+import { signOut } from "@/app/backend/session-management";
+import { Session } from "next-auth";
 
+interface HeaderProps {
+  session: Session | null;
+}
 /**
  * Produces the header.
- * @param root0 - The properties object
- * @param root0.authDisabled - The server-side read of the auth disabled environment variable.
+ * @param param0 - param
+ * @param param0.session - whether user is logged in
  * @returns The HeaderComponent component.
  */
-const HeaderComponent: React.FC<{ authDisabled: boolean }> = ({
-  authDisabled,
-}) => {
+const HeaderComponent: React.FC<HeaderProps> = ({ session }) => {
   const menuRef = useRef<HTMLDivElement>(null);
   const [showMenu, setShowMenu] = useState(false);
   const router = useRouter();
-  const { data: session, status } = useSession();
   const ctx = useContext(DataContext);
   const isAuthDisabled = isAuthDisabledClientCheck(ctx?.runtimeConfig);
 
-  const isLoggedIn = status === "authenticated" || isAuthDisabled;
+  const isLoggedIn = session !== null || isAuthDisabled;
 
-  const userRole = authDisabled
+  const userRole = isAuthDisabled
     ? UserRole.SUPER_ADMIN
     : session?.user?.role || "";
 
@@ -53,9 +54,10 @@ const HeaderComponent: React.FC<{ authDisabled: boolean }> = ({
   }, [showMenu]);
 
   const handleSignOut = async () => {
-    if (authDisabled) {
+    if (isAuthDisabled) {
       router.push(PAGES.LANDING);
     } else {
+      setShowMenu(false);
       await signOut({ redirectTo: PAGES.LANDING });
     }
   };
@@ -64,7 +66,8 @@ const HeaderComponent: React.FC<{ authDisabled: boolean }> = ({
     setShowMenu(!showMenu);
   };
 
-  const landingPage = authDisabled || isLoggedIn ? PAGES.QUERY : PAGES.LANDING;
+  const landingPage =
+    isAuthDisabled || isLoggedIn ? PAGES.QUERY : PAGES.LANDING;
 
   const menuPages = getPagesInSettingsMenu(userRole as UserRole).filter(
     (page) => page.path !== PAGES.QUERY,
@@ -152,7 +155,7 @@ const HeaderComponent: React.FC<{ authDisabled: boolean }> = ({
                   </Link>
                 </li>
               ))}
-              {!authDisabled && (
+              {!isAuthDisabled && (
                 <li className={styles.subMenuItem}>
                   <button
                     className={classNames(
diff --git a/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx b/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx
index b9d3ceb2b..df532ff99 100644
--- a/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx
+++ b/src/app/ui/components/sessionTimeout/sessionTimeout.test.tsx
@@ -6,9 +6,13 @@ import SessionTimeout from "./sessionTimeout";
 import { UserRole } from "@/app/models/entities/users";
 import { Session } from "next-auth";
 import { PAGES } from "@/app/shared/page-routes";
+import { signOut } from "@/app/backend/session-management";
 
 jest.mock("next-auth/react");
 jest.mock("tabbable");
+jest.mock("@/app/backend/session-management", () => ({
+  signOut: jest.fn(),
+}));
 
 describe("SessionTimeout", () => {
   it("Does not display when user is unauthenticated", async () => {
@@ -43,7 +47,6 @@ describe("SessionTimeout", () => {
     };
 
     jest.spyOn(nextAuthReact, "useSession").mockReturnValue(session);
-    const signOutSpy = jest.spyOn(nextAuthReact, "signOut");
 
     render(
       <RootProviderMock currentPage={"/"}>
@@ -56,6 +59,8 @@ describe("SessionTimeout", () => {
       expect(dialog).toHaveAttribute("class", "usa-modal-wrapper is-visible"),
     );
 
+    const signOutSpy = signOut as jest.Mock;
+
     await waitFor(() =>
       expect(signOutSpy).toHaveBeenCalledWith({ redirectTo: PAGES.LANDING }),
     );
@@ -70,7 +75,7 @@ describe("SessionTimeout", () => {
     };
 
     jest.spyOn(nextAuthReact, "useSession").mockReturnValue(session);
-    const signOutSpy = jest.spyOn(nextAuthReact, "signOut");
+    const signOutSpy = signOut as jest.Mock;
 
     const { user } = renderWithUser(
       <RootProviderMock currentPage={"/"}>
@@ -101,7 +106,7 @@ describe("SessionTimeout", () => {
     };
 
     jest.spyOn(nextAuthReact, "useSession").mockReturnValue(session);
-    const signOutSpy = jest.spyOn(nextAuthReact, "signOut");
+    const signOutSpy = signOut as jest.Mock;
 
     render(
       <RootProviderMock currentPage={"/"}>
diff --git a/src/app/ui/components/sessionTimeout/sessionTimeout.tsx b/src/app/ui/components/sessionTimeout/sessionTimeout.tsx
index 85ed0ba31..dd8c70c4b 100644
--- a/src/app/ui/components/sessionTimeout/sessionTimeout.tsx
+++ b/src/app/ui/components/sessionTimeout/sessionTimeout.tsx
@@ -1,11 +1,12 @@
 "use client";
 
-import { signOut, useSession } from "next-auth/react";
+import { useSession } from "next-auth/react";
 import { useIdleTimer } from "react-idle-timer";
 import type { ModalProps, ModalRef } from "../../designSystem/modal/Modal";
 import { useEffect, useRef, useState } from "react";
 import dynamic from "next/dynamic";
 import { PAGES } from "@/app/shared/page-routes";
+import { signOut } from "@/app/backend/session-management";
 
 const Modal = dynamic<ModalProps>(
   () => import("../../designSystem/modal/Modal").then((mod) => mod.Modal),
diff --git a/src/auth.ts b/src/auth.ts
index 85f4df672..43a0cf01b 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -3,6 +3,7 @@ import { addUserIfNotExists, getUserRole } from "@/app/backend/user-management";
 import { isAuthDisabledServerCheck } from "./app/utils/auth";
 import { UserRole } from "./app/models/entities/users";
 import NextAuth from "next-auth";
+import { logSignInToAuditTable } from "./app/backend/session-management";
 
 function addRealm(url: string) {
   return url.endsWith("/realms/master") ? url : `${url}/realms/master`;
@@ -116,6 +117,11 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
 
       return session;
     },
+
+    async signIn({ profile }) {
+      logSignInToAuditTable(profile);
+      return true;
+    },
   },
   // WARNING: Turning this on will log out session info (which in all likelihood)
   // will contain PII into the system logs whenever users log in/out. Proceed


Reviews:
user: login: fzhao99
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T18:17:05Z
user: login: fzhao99
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T18:19:56Z
user: login: robertandremitchell
body: looks good to me! structure overall makes sense, though I do wonder if we would want to capture the actual user's names and group names in the topline level of the audit log? Especially in the event of a user group deletion, it might be harder to trace back which group was deleted with just the ID. 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T20:50:46Z
user: login: nickclyde
body: Looks good, thanks Bob!!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-05T22:14:16Z

Review comments:
diff_hunk: @@ -241,59 +241,6 @@ class UserGroupManagementService {
     }
   }
 
-  /**
-   * Saves the user group membership for a specific user group.
-   * @param groupId - The unique identifier of the user group.
-   * @param selectedUsers - The unique identifiers of the users to add to the group.
-   * @returns A list of user group memberships for the specified group.
-   */
-  @transaction
-  @adminRequired
-  static async saveUserGroupMembership(
path: src/app/backend/usergroup-management.ts
user: login: fzhao99
body: this function wasn't getting used anywhere (since I think we're just using the add / remove functions directly) so I went ahead and deleted it / the corresponding test
created_at: 2025-05-05T18:17:05Z
author_association: COLLABORATOR
diff_hunk: @@ -571,19 +571,6 @@ class DatabaseService {
     return match ? match[1] : systemURL;
   }
 
-  private static logRejectedPromiseReasons<T>(
path: src/app/shared/database-service.ts
user: login: fzhao99
body: this function wasn't getting used anywhere so I deleted it
created_at: 2025-05-05T18:19:56Z
author_association: COLLABORATOR
diff_hunk: @@ -142,11 +142,7 @@ export function camelCaseDbColumnNames<T extends Record<string, unknown>>(
       }
 
       result.rows = result.rows.map((v) => {
-        const val: Record<string, unknown> = {};
-        Object.entries(v).forEach(([k, v]) => {
-          val[translateSnakeStringToCamelCase(k)] = v;
-        });
-
+        const val = translateNestedObjectKeysIntoCamelCase(v as object);
path: src/app/backend/dbServices/decorators.ts
user: login: nickclyde
body: Nice, much cleaner
created_at: 2025-05-05T22:12:43Z
author_association: MEMBER
diff_hunk: @@ -165,26 +154,91 @@ describe("audit log", () => {
       mrn: hyperUnluckyPatient.MRN,
       phone: hyperUnluckyPatient.Phone,
     };
-
     await patientDiscoveryQuery(request);
-
     const result = await dbClient.query(
       "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1",
     );
     const insertedId = result.rows[0].id;
-
     await expect(
       dbClient.query(`UPDATE audit_logs SET author = 'hacker' WHERE id = $1`, [
         insertedId,
       ]),
     ).rejects.toThrow(/UPDATEs to audit_logs are not permitted/i);
-
     await expect(
       dbClient.query(`DELETE FROM audit_logs WHERE id = $1`, [insertedId]),
     ).rejects.toThrow(/DELETEs from audit_logs are not permitted/i);
   });
 });
 
+describe("sign in and out", () => {
+  it("sign out should generate an audit entry", async () => {
path: src/app/tests/integration/audit-logging.test.ts
user: login: nickclyde
body: 👍🏻 
created_at: 2025-05-05T22:14:00Z
author_association: MEMBER

PR comments:
user: login: linear[bot]
created_at: 2025-05-05T13:57:35Z
body: <p><a href="https://linear.app/skylight-cdc/issue/QUE-194/methods-handing-user-session-management-are-marked-as-auditable">QUE-194 Methods handing user session management are marked as `@auditable`</a></p>
author_association: NONE
user: login: codecov-commenter
created_at: 2025-05-05T14:13:36Z
body: ## [Codecov](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov) Report
:x: Patch coverage is `45.60000%` with `68 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 65.88%. Comparing base ([`9041b77`](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/commit/9041b77f835763bc407fd692b35208fedfc8e605?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov)) to head ([`b2add59`](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/commit/b2add59e1f4a1bbe8bd4a241e7ead02b9e7b717d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov)).
:warning: Report is 158 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov) | Patch % | Lines |
|---|---|---|
| [...c/app/(pages)/auditLogs/components/auditLogMaps.ts](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&filepath=src%2Fapp%2F%28pages%29%2FauditLogs%2Fcomponents%2FauditLogMaps.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov#diff-c3JjL2FwcC8ocGFnZXMpL2F1ZGl0TG9ncy9jb21wb25lbnRzL2F1ZGl0TG9nTWFwcy50cw==) | 3.27% | [59 Missing :warning: ](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov) |
| [src/app/backend/dbServices/util.ts](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&filepath=src%2Fapp%2Fbackend%2FdbServices%2Futil.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov#diff-c3JjL2FwcC9iYWNrZW5kL2RiU2VydmljZXMvdXRpbC50cw==) | 86.66% | [4 Missing :warning: ](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov) |
| [src/auth.ts](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&filepath=src%2Fauth.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov#diff-c3JjL2F1dGgudHM=) | 25.00% | [3 Missing :warning: ](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov) |
| [src/app/backend/session-management.ts](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&filepath=src%2Fapp%2Fbackend%2Fsession-management.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov#diff-c3JjL2FwcC9iYWNrZW5kL3Nlc3Npb24tbWFuYWdlbWVudC50cw==) | 85.71% | [2 Missing :warning: ](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov) |

<details><summary>Additional details and impacted files</summary>



```diff
@@            Coverage Diff             @@
##             main     #577      +/-   ##
==========================================
- Coverage   66.31%   65.88%   -0.43%     
==========================================
  Files         100      101       +1     
  Lines        4085     4154      +69     
  Branches      903      926      +23     
==========================================
+ Hits         2709     2737      +28     
+ Misses       1366     1348      -18     
- Partials       10       69      +59     
```
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/CDCgov/dibbs-query-connector/pull/577?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=CDCgov).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: fzhao99
created_at: 2025-05-05T21:51:45Z
body: > I do wonder if we would want to capture the actual user's names and group names

this is a little more complicated to do since that info isn't captured in the function inputs. think you'd be able to recover that information by searching for the ID of the newly deleted group and then determine the chain of additions / updates that maps to the group information at the time of deletion. From what I understand this is what we'd need for compliance, but lmk if you think we should look into developing this out further to include that info
author_association: COLLABORATOR
user: login: robertandremitchell
created_at: 2025-05-06T12:53:01Z
body: > > I do wonder if we would want to capture the actual user's names and group names
> 
> this is a little more complicated to do since that info isn't captured in the function inputs. think you'd be able to recover that information by searching for the ID of the newly deleted group and then determine the chain of additions / updates that maps to the group information at the time of deletion. From what I understand this is what we'd need for compliance, but lmk if you think we should look into developing this out further to include that info

I don't think so. I think we should aim for compliance first. I think we will probably wanna have a design/beautification round to make sure the language of the action text is all aligned and makes sense to users, but that probably makes sense to do once we have everything working and compliant.
author_association: COLLABORATOR