diff --git a/backend/compact-connect/lambdas/nodejs/yarn.lock b/backend/compact-connect/lambdas/nodejs/yarn.lock index af62e8dc5e..7c2b9ec127 100644 --- a/backend/compact-connect/lambdas/nodejs/yarn.lock +++ b/backend/compact-connect/lambdas/nodejs/yarn.lock @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== napi-postinstall@^0.3.4: version "0.3.4" diff --git a/backend/cosmetology-app/lambdas/nodejs/yarn.lock b/backend/cosmetology-app/lambdas/nodejs/yarn.lock index af62e8dc5e..7c2b9ec127 100644 --- a/backend/cosmetology-app/lambdas/nodejs/yarn.lock +++ b/backend/cosmetology-app/lambdas/nodejs/yarn.lock @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== napi-postinstall@^0.3.4: version "0.3.4" diff --git a/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts b/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts index a10b985715..f846ca64f8 100644 --- a/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts +++ b/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts @@ -348,6 +348,24 @@ export class Lambda implements LambdaInterface { event.templateVariables.newJurisdiction ); break; + case 'staffUserInactivityNotification': + if (!event.templateVariables?.staffUserFirstName + || !event.templateVariables?.staffUserLastName + || !event.templateVariables?.staffUserEmail + || !event.templateVariables?.deactivationDate + || !event.templateVariables?.inactivityPeriodDays) { + throw new Error('Missing required template variables for staffUserInactivityNotification template.'); + } + await this.emailService.sendStaffUserInactivityNotificationEmail( + event.compact, + event.specificEmails || [], + event.templateVariables.staffUserFirstName, + event.templateVariables.staffUserLastName, + event.templateVariables.staffUserEmail, + event.templateVariables.deactivationDate, + event.templateVariables.inactivityPeriodDays + ); + break; default: logger.info('Unsupported email template provided', { template: event.template }); throw new Error(`Unsupported email template: ${event.template}`); diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts index ea1543e8e6..e0f5a63fff 100644 --- a/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts +++ b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts @@ -4,6 +4,15 @@ import { RecipientType } from '../models/email-notification-service-event'; const environmentVariableService = new EnvironmentVariablesService(); +/** Format an ISO 8601 date string (YYYY-MM-DD) for display as MM/DD/YYYY (e.g. "09/14/2026"). Timezone-neutral. */ +function formatIsoDateAsSlashFormat(isoDate: string): string { + const [year, month, day] = isoDate.split('-').map(Number); + const paddedMonth = String(month).padStart(2, '0'); + const paddedDay = String(day).padStart(2, '0'); + + return `${paddedMonth}/${paddedDay}/${year}`; +} + /** * Email service for handling email notifications */ @@ -77,4 +86,59 @@ export class EmailNotificationService extends BaseEmailService { await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send home jurisdiction change state notification email' }); } + + /** + * Sends a notification that a staff user's account is scheduled for inactivity deactivation. + * + * The body is written in the third person so the same email serves the staff user and their + * administrators, and states an absolute date rather than a countdown so it stays true whenever it is read. + * + * @param compact - The compact name + * @param specificEmails - The address(es) to send this notification to + * @param staffUserFirstName - The affected staff user's first name + * @param staffUserLastName - The affected staff user's last name + * @param staffUserEmail - The affected staff user's email address + * @param deactivationDate - ISO 8601 date string (YYYY-MM-DD) the account is deactivated on + * @param inactivityPeriodDays - How many days of inactivity trigger deactivation + */ + public async sendStaffUserInactivityNotificationEmail( + compact: string, + specificEmails: string[], + staffUserFirstName: string, + staffUserLastName: string, + staffUserEmail: string, + deactivationDate: string, + inactivityPeriodDays: number + ): Promise { + this.logger.info('Sending staff user inactivity notification email', { compact: compact }); + + if (specificEmails.length === 0) { + throw new Error('No recipients found for staff user inactivity notification email'); + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const staffUserName = `${staffUserFirstName} ${staffUserLastName}`; + const deactivationDateDisplay = formatIsoDateAsSlashFormat(deactivationDate); + const subject = `CompactConnect account for ${staffUserName} will be deactivated on ${deactivationDateDisplay}`; + + const report = this.getNewEmailTemplate(); + const bodyText = `The ${compactConfig.compactName} CompactConnect account for ${staffUserName} (${staffUserEmail}) will be deactivated on **${deactivationDateDisplay}**. CompactConnect deactivates staff user accounts after ${inactivityPeriodDays} days with no sign-in activity.\n\n` + + `To prevent deactivation, ${staffUserName} should sign in to CompactConnect before **${deactivationDateDisplay}**.\n\n` + + `If the account has already been deactivated, an administrator will need to re-invite ${staffUserName} to CompactConnect to restore access.\n\n` + + `Sign in: ${environmentVariableService.getUiBasePathUrl()}/Dashboard`; + + this.insertHeader(report, 'Account Deactivation Notice'); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ + htmlContent, + subject, + recipients: specificEmails, + errorMessage: 'Unable to send staff user inactivity notification email' + }); + } + } diff --git a/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts index fa9886abf5..06b339b6e2 100644 --- a/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts +++ b/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts @@ -1168,4 +1168,58 @@ describe('EmailNotificationServiceLambda', () => { .toThrow('Missing required template variables for home jurisdiction change notification template.'); }); }); + + describe('Staff User Inactivity Notification', () => { + const SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'staffUserInactivityNotification', + recipientType: 'SPECIFIC', + compact: 'socw', + specificEmails: ['jane@example.com'], + templateVariables: { + staffUserFirstName: 'Jane', + staffUserLastName: 'Smith', + staffUserEmail: 'jane@example.com', + deactivationDate: '2026-09-14', + inactivityPeriodDays: 60 + } + }; + + it('should successfully send staff user inactivity notification email', async () => { + mockDynamoDBClient.on(GetItemCommand).resolves({ Item: SAMPLE_COMPACT_CONFIGURATION }); + + const response = await lambda.handler( + SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT, + {} as any + ); + + expect(response).toEqual({ message: 'Email message sent' }); + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['jane@example.com'] + } + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for staffUserInactivityNotification template.'); + }); + + it('should throw error when no recipients are provided', async () => { + const eventWithNoRecipients: EmailNotificationEvent = { + ...SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT, + specificEmails: [] + }; + + await expect(lambda.handler(eventWithNoRecipients, {} as any)) + .rejects + .toThrow('No recipients found for staff user inactivity notification email'); + }); + }); }); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts index b61f4c6331..e5406aea8f 100644 --- a/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts @@ -201,4 +201,86 @@ describe('EmailNotificationService', () => { )).rejects.toThrow('No recipients found for jurisdiction oh in compact aslp'); }); }); + + describe('Staff User Inactivity Notification', () => { + const sendInactivityNotification = (recipients: string[] = ['jane@example.com']) => + emailService.sendStaffUserInactivityNotificationEmail( + 'aslp', + recipients, + 'Jane', + 'Smith', + 'jane@example.com', + '2026-09-14', + 60 + ); + + it('should send the notification with expected subject and content', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await sendInactivityNotification(['jane@example.com', 'admin@example.com']); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['jane@example.com', 'admin@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'CompactConnect account for Jane Smith will be deactivated on 09/14/2026' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should render the deactivation date, the account it refers to, and the recovery path', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await sendInactivityNotification(); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + // Third person throughout, so the same body serves the user and their administrators + expect(htmlContent).toContain('Jane Smith'); + expect(htmlContent).toContain('jane@example.com'); + expect(htmlContent).toContain('09/14/2026'); + expect(htmlContent).toContain('60 days'); + expect(htmlContent).toContain('re-invite'); + expect(htmlContent).toContain('https://app.test.compactconnect.org/Dashboard'); + }); + + it('should render the deactivation date in bold everywhere it appears in the body', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await sendInactivityNotification(); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + // The date appears twice in the body: once stating the deactivation date, once as the + // sign-in deadline. Both should be emphasized so the date doesn't get lost in the paragraph. + expect(htmlContent?.match(/09\/14\/2026<\/strong>/g)).toHaveLength(2); + }); + + it('should throw when there are no recipients', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await expect(sendInactivityNotification([])) + .rejects + .toThrow('No recipients found for staff user inactivity notification email'); + }); + }); }); diff --git a/backend/social-work-app/lambdas/nodejs/yarn.lock b/backend/social-work-app/lambdas/nodejs/yarn.lock index af62e8dc5e..7c2b9ec127 100644 --- a/backend/social-work-app/lambdas/nodejs/yarn.lock +++ b/backend/social-work-app/lambdas/nodejs/yarn.lock @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== napi-postinstall@^0.3.4: version "0.3.4" diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py index e69de29bb2..b3926bc48b 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py @@ -0,0 +1,70 @@ +# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition + +from datetime import datetime +from uuid import UUID + +from cc_common.data_model.schema.common import CCDataClass, CCPermissionsAction +from cc_common.data_model.schema.user.record import UserRecordSchema + + +class StaffUserData(CCDataClass): + """ + Class representing a Staff User with getters for all properties. + """ + + # Define record schema at the class level + _record_schema = UserRecordSchema() + + # Require valid data when creating instances + _requires_data_at_construction = True + + @property + def userId(self) -> UUID: + return self._data['userId'] + + @property + def compact(self) -> str: + return self._data['compact'] + + @property + def status(self) -> str: + return self._data['status'] + + @property + def lastLoginAt(self) -> datetime | None: + """Absent for users who have not signed in since login tracking was introduced.""" + return self._data.get('lastLoginAt') + + # The attributes field is flattened here, since its nesting is a storage detail + @property + def email(self) -> str: + return self._data['attributes']['email'] + + @property + def givenName(self) -> str: + return self._data['attributes']['givenName'] + + @property + def familyName(self) -> str: + return self._data['attributes']['familyName'] + + @property + def compactActions(self) -> set[str]: + """Compact-level actions. Empty if the user only has jurisdiction permissions.""" + return self._data['permissions'].get('actions', set()) + + @property + def jurisdictions(self) -> set[str]: + """Jurisdiction codes where this user holds any permission.""" + return set(self._data['permissions']['jurisdictions'].keys()) + + def jurisdictionActions(self, jurisdiction: str) -> set[str]: + """The user's actions in one jurisdiction. Empty if they hold no permissions there.""" + return self._data['permissions']['jurisdictions'].get(jurisdiction, set()) + + @property + def isCompactAdmin(self) -> bool: + return CCPermissionsAction.ADMIN in self.compactActions + + def isJurisdictionAdmin(self, jurisdiction: str) -> bool: + return CCPermissionsAction.ADMIN in self.jurisdictionActions(jurisdiction) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py index 010a8bb5a2..8d17a7a4e3 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py @@ -45,6 +45,8 @@ class UserAPISchema(Schema): userId = Raw(required=True, allow_none=False) status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus])) dateOfUpdate = Raw(required=True, allow_none=False) + # Absent for users who have not signed in + lastLoginAt = Raw(required=False, allow_none=False) attributes = Nested(UserAttributesAPISchema(), required=True, allow_none=False) permissions = Dict( keys=String(validate=OneOf(config.compacts)), # Key is one compact diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py index 715fccf877..4622ceeded 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py @@ -1,6 +1,6 @@ # ruff: noqa: N801, N815 invalid-name from marshmallow import Schema, post_dump, post_load, pre_dump -from marshmallow.fields import UUID, Dict, Nested, String +from marshmallow.fields import UUID, AwareDateTime, Dict, Nested, String from marshmallow.validate import Length, OneOf from cc_common.config import config @@ -49,6 +49,9 @@ class UserRecordSchema(BaseRecordSchema): compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) permissions = Nested(CompactPermissionsRecordSchema(), required=True, allow_none=False) status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus])) + # Set by the pre-token-generation hook on every successful access token generation. Absent for users who + # have not signed in + lastLoginAt = AwareDateTime(required=False, allow_none=False) # Generated fields famGiv = String(required=True, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index eea8686687..4ae458aa2c 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable +from collections.abc import Generator, Iterable from enum import StrEnum from secrets import token_hex @@ -8,6 +8,7 @@ from cc_common.config import _Config, logger from cc_common.data_model.query_paginator import paginated_query from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.user import StaffUserData from cc_common.data_model.schema.user.record import ( CompactPermissionsRecordSchema, UserAttributesRecordSchema, @@ -94,6 +95,96 @@ def get_users_sorted_by_family_name( **dynamo_pagination, ) + def deactivate_user(self, *, user_id: str) -> None: + """Deactivate a staff user for inactivity. + + Disables the Cognito user so they can no longer obtain a token, and marks every one of their + compact records inactive so the rest of the system reflects that. + + :param str user_id: The user to deactivate + """ + logger.info('Deactivating staff user', user_id=user_id) + + # Disable in Cognito first. If this succeeds but the record updates below do not, the user is + # locked out while still showing active, and the next day's scheduled day-of run retries the + # DynamoDB update alone (the inactivity tracker records each step separately, so it knows this + # one didn't succeed). The reverse order would leave a user marked inactive who can still sign + # in - and the pre-token hook would then flip them straight back to active. + self.config.cognito_client.admin_disable_user(UserPoolId=self.config.user_pool_id, Username=user_id) + + # A user only ever has a handful of compact records, all in one partition, so a single query + # is enough. We update each record by its own keys rather than rebuilding them. + user_records = self.config.users_table.query(KeyConditionExpression=Key('pk').eq(f'USER#{user_id}')).get( + 'Items', [] + ) + date_of_update = self.config.current_standard_datetime.isoformat() + for record in user_records: + self.config.users_table.update_item( + Key={'pk': record['pk'], 'sk': record['sk']}, + UpdateExpression='SET #status = :status, dateOfUpdate = :dateOfUpdate', + ExpressionAttributeNames={'#status': 'status'}, + ExpressionAttributeValues={ + ':status': StaffUserStatus.INACTIVE.value, + ':dateOfUpdate': date_of_update, + }, + ) + + def iterate_all_users_in_compact(self, *, compact: str) -> Generator[StaffUserData, None, None]: + """Yield every staff user in a compact, transparently following LastEvaluatedKey. + + Distinct from get_users_sorted_by_family_name, which returns a single API-facing page. This is + for batch work that has to consider every user in the compact. + + :param str compact: The compact to iterate over + """ + logger.info('Iterating over all staff users in compact', compact=compact) + + pagination = {} + while True: + response = self.config.users_table.query( + IndexName=self.config.fam_giv_index_name, + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('sk').eq(f'COMPACT#{compact}'), + **pagination, + ) + for item in response.get('Items', []): + yield StaffUserData.from_database_record(item) + + last_key = response.get('LastEvaluatedKey') + if last_key is None: + break + pagination = {'ExclusiveStartKey': last_key} + + def record_user_login(self, *, user_id: str, compacts: Iterable[str]) -> None: + """Mark the user active and stamp lastLoginAt on each of the user's compact records. + + Called from the pre-token-generation hook on every successful access token generation. + + :param str user_id: The user that just signed in + :param Iterable[str] compacts: The compacts the user has records in + """ + logger.info('Recording staff user login', user_id=user_id) + + last_login_at = self.config.current_standard_datetime.isoformat() + for compact in compacts: + try: + self.config.users_table.update_item( + Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, + UpdateExpression='SET #status = :status, lastLoginAt = :lastLoginAt', + # Without this, an update against a missing record would create a stub record that has + # none of the fields a user record requires + ConditionExpression=Attr('pk').exists(), + ExpressionAttributeNames={'#status': 'status'}, + ExpressionAttributeValues={ + ':status': StaffUserStatus.ACTIVE.value, + ':lastLoginAt': last_login_at, + }, + ) + except ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + raise CCNotFoundException('User not found for compact') from e + raise + def update_user_permissions( self, *, @@ -191,7 +282,8 @@ def _handle_user_permission_additions( expression_attribute_values[f':{jurisdiction}AddActions'] = actions if update_expression_parts: - update_expression = 'ADD ' + ', '.join(update_expression_parts) + update_expression = 'ADD ' + ', '.join(update_expression_parts) + ' SET dateOfUpdate = :dateOfUpdate' + expression_attribute_values[':dateOfUpdate'] = self.config.current_standard_datetime.isoformat() try: return self.config.users_table.update_item( @@ -238,7 +330,8 @@ def _handle_user_permission_removals( expression_attribute_values[f':{jurisdiction}DeleteActions'] = actions if update_expression_parts: - update_expression = 'DELETE ' + ', '.join(update_expression_parts) + update_expression = 'DELETE ' + ', '.join(update_expression_parts) + ' SET dateOfUpdate = :dateOfUpdate' + expression_attribute_values[':dateOfUpdate'] = self.config.current_standard_datetime.isoformat() return self.config.users_table.update_item( Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, @@ -276,6 +369,9 @@ def update_user_attributes(self, *, user_id: str, attributes: dict): expression_attribute_names[f'#{attr_name}'] = attr_name expression_attribute_values[f':{attr_name}'] = attr_value + update_expression_parts.append('dateOfUpdate = :dateOfUpdate') + expression_attribute_values[':dateOfUpdate'] = self.config.current_standard_datetime.isoformat() + update_expression = 'SET ' + ', '.join(update_expression_parts) records = self.get_user(user_id=user_id)['items'] @@ -402,6 +498,15 @@ def reinvite_user(self, *, email: str) -> None: Username=email, ) + # A user deactivated for inactivity is disabled in Cognito. Re-enable them before resending + # the invite, or the invitation arrives but they still cannot sign in. + if not user_data.get('Enabled', True): + logger.info('Re-enabling disabled user before reinvite') + self.config.cognito_client.admin_enable_user( + UserPoolId=self.config.user_pool_id, + Username=email, + ) + # If they're in CONFIRMED state, we need to reset their password first if user_data['UserStatus'] in (UserStatus.CONFIRMED, UserStatus.RESET_REQUIRED): self.config.cognito_client.admin_set_user_password( diff --git a/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py b/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py index cfbb344a23..c82f0bc3ae 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py @@ -51,6 +51,19 @@ class HomeJurisdictionChangeNotificationTemplateVariables: provider_id: UUID +@dataclass +class StaffUserInactivityNotificationTemplateVariables: + """ + Template variables for staff user inactivity deactivation notification emails. + """ + + staff_user_first_name: str + staff_user_last_name: str + staff_user_email: str + deactivation_date: date + inactivity_period_days: int + + class JurisdictionNotificationMethod(Protocol): """Protocol for Jurisdiction encumbrance notification methods.""" @@ -415,3 +428,35 @@ def send_provider_home_state_change_email( }, } return self._invoke_lambda(payload) + + def send_staff_user_inactivity_notification_email( + self, + *, + compact: str, + recipient_emails: list[str], + template_variables: StaffUserInactivityNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a notification that a staff user's account is scheduled for inactivity deactivation. + + The body is worded in the third person so the same email serves the staff user and their administrators. + + :param compact: Compact name + :param recipient_emails: The addresses to send this notification to + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + payload = { + 'compact': compact, + 'template': 'staffUserInactivityNotification', + 'recipientType': 'SPECIFIC', + 'specificEmails': recipient_emails, + 'templateVariables': { + 'staffUserFirstName': template_variables.staff_user_first_name, + 'staffUserLastName': template_variables.staff_user_last_name, + 'staffUserEmail': template_variables.staff_user_email, + 'deactivationDate': template_variables.deactivation_date.isoformat(), + 'inactivityPeriodDays': template_variables.inactivity_period_days, + }, + } + return self._invoke_lambda(payload) diff --git a/backend/social-work-app/lambdas/python/common/common_test/test_constants.py b/backend/social-work-app/lambdas/python/common/common_test/test_constants.py index d343d88163..f620bd3bd1 100644 --- a/backend/social-work-app/lambdas/python/common/common_test/test_constants.py +++ b/backend/social-work-app/lambdas/python/common/common_test/test_constants.py @@ -63,6 +63,10 @@ DEFAULT_EMAIL_ADDRESS = 'björk@example.com' DEFAULT_PHONE_NUMBER = '+13213214321' +# Staff user defaults +DEFAULT_STAFF_USER_ID = 'a4182428-d061-701c-82e5-a3d1d547d797' +DEFAULT_STAFF_USER_LAST_LOGIN_TIMESTAMP = '2024-11-08T23:59:59+00:00' + # record type constants ADVERSE_ACTION_RECORD_TYPE = 'adverseAction' LICENSE_RECORD_TYPE = 'license' diff --git a/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py b/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py index 693ee2739c..bc099555a8 100644 --- a/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py +++ b/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py @@ -10,6 +10,7 @@ from cc_common.data_model.schema.jurisdiction import JurisdictionConfigurationData from cc_common.data_model.schema.license import LicenseData, LicenseUpdateData from cc_common.data_model.schema.provider import ProviderData, ProviderUpdateData +from cc_common.data_model.schema.user import StaffUserData from cc_common.utils import ResponseEncoder from common_test.test_constants import * @@ -153,6 +154,29 @@ def generate_default_adverse_action(value_overrides: dict | None = None) -> Adve return AdverseActionData.create_new(default_adverse_actions) + @staticmethod + def generate_default_staff_user(value_overrides: dict | None = None) -> StaffUserData: + """Generate a default staff user with no permissions""" + from cc_common.data_model.schema.common import StaffUserStatus + + default_staff_user = { + 'userId': DEFAULT_STAFF_USER_ID, + 'compact': DEFAULT_COMPACT, + 'type': 'user', + 'status': StaffUserStatus.ACTIVE.value, + 'attributes': { + 'email': DEFAULT_EMAIL_ADDRESS, + 'givenName': DEFAULT_GIVEN_NAME, + 'familyName': DEFAULT_FAMILY_NAME, + }, + 'permissions': {'actions': set(), 'jurisdictions': {}}, + 'lastLoginAt': datetime.fromisoformat(DEFAULT_STAFF_USER_LAST_LOGIN_TIMESTAMP), + } + if value_overrides: + default_staff_user.update(value_overrides) + + return StaffUserData.create_new(default_staff_user) + @staticmethod def generate_default_investigation(value_overrides: dict | None = None) -> InvestigationData: """Generate a default investigation""" diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py index e2f501d68a..3ed93b65f4 100644 --- a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -7,8 +7,14 @@ from .. import TstFunction +# Frozen "now" for the whole class, so any write that stamps a timestamp is assertable by value. The +# user.json fixture carries an older dateOfUpdate, so a record still holding that value has not been +# written by the code under test. +MOCK_DATETIME = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + @mock_aws +@patch('cc_common.config._Config.current_standard_datetime', MOCK_DATETIME) class TestClient(TstFunction): def _get_email_from_user_attributes(self, user_data: dict) -> str: for attribute in user_data['UserAttributes']: @@ -31,6 +37,247 @@ def test_get_user_in_compact(self): ) self.assertEqual(UUID(user_id), user['userId']) + def _get_user_record(self, user_id: str, compact: str = 'socw') -> dict: + return self.config.users_table.get_item(Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'})['Item'] + + def test_record_user_login_sets_last_login_at_and_status(self): + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + # The fixture user has never signed in + self.assertEqual(StaffUserStatus.INACTIVE.value, self._get_user_record(user_id)['status']) + + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) + + user_record = self._get_user_record(user_id) + self.assertEqual(MOCK_DATETIME.isoformat(), user_record['lastLoginAt']) + self.assertEqual(StaffUserStatus.ACTIVE.value, user_record['status']) + + def test_record_user_login_refreshes_last_login_at_for_active_user(self): + """An already-active user still gets a fresh lastLoginAt on every sign-in.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + client = UserClient(self.config) + + # The first login uses the class-wide frozen time; the second overrides it, since this test is + # specifically about the stamp moving between two sign-ins + client.record_user_login(user_id=user_id, compacts=['socw']) + + second_login = datetime.fromisoformat('2024-12-25T08:00:00+00:00') + with patch('cc_common.config._Config.current_standard_datetime', second_login): + client.record_user_login(user_id=user_id, compacts=['socw']) + + self.assertEqual(second_login.isoformat(), self._get_user_record(user_id)['lastLoginAt']) + + def test_record_user_login_leaves_other_fields_untouched(self): + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + original_record = self._get_user_record(user_id) + + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) + + updated_record = self._get_user_record(user_id) + for field in ('attributes', 'permissions', 'famGiv', 'compact', 'type', 'userId'): + self.assertEqual(original_record[field], updated_record[field], f'{field} should not have changed') + + def test_record_user_login_updates_every_compact_record(self): + """A user with records in several compacts gets every one of them stamped.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + # Seed a second compact record for the same user. Written directly, since the record schema only + # permits the compacts this app is configured for. + self.config.users_table.put_item( + Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} + ) + + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + + for compact in ('socw', 'some-other-compact'): + self.assertEqual( + MOCK_DATETIME.isoformat(), + self._get_user_record(user_id, compact)['lastLoginAt'], + f'the {compact} record should have been stamped', + ) + + def test_record_user_login_raises_when_record_does_not_exist(self): + """An update against a missing record must not silently create a stub user record. + + The condition is evaluated per item, so a user existing in one compact does not cover a + compact they have no record in. This stamps socw before it raises for the missing compact - + a partial update is acceptable here, since the next successful sign-in re-stamps everything. + """ + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCNotFoundException + + # This user only has a socw record + user_id = self._load_user_data() + + with self.assertRaises(CCNotFoundException): + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + + stub_record = self.config.users_table.get_item( + Key={'pk': f'USER#{user_id}', 'sk': 'COMPACT#some-other-compact'} + ) + self.assertNotIn('Item', stub_record) + + def _is_cognito_user_enabled(self, user_id: str) -> bool: + return self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=user_id)[ + 'Enabled' + ] + + def test_deactivate_user_disables_the_cognito_user(self): + """A deactivated user must not be able to obtain a token, so Cognito is the real lock.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + UserClient(self.config).deactivate_user(user_id=user_id) + + self.assertFalse(self._is_cognito_user_enabled(user_id)) + + def test_deactivate_user_marks_every_compact_record_inactive(self): + """The Cognito disable is global, so leaving another compact's record active would be a lie.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + # Seed a second compact record for the same user, written directly since the record schema + # only permits the compacts this app is configured for. + self.config.users_table.put_item( + Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} + ) + client = UserClient(self.config) + client.record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + + client.deactivate_user(user_id=user_id) + + for compact in ('socw', 'some-other-compact'): + self.assertEqual( + StaffUserStatus.INACTIVE.value, + self._get_user_record(user_id, compact)['status'], + f'the {compact} record should have been marked inactive', + ) + + def test_deactivate_user_is_idempotent(self): + """The day-of sweep can retry, so deactivating an already-deactivated user must not raise.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + client = UserClient(self.config) + + client.deactivate_user(user_id=user_id) + client.deactivate_user(user_id=user_id) + + self.assertFalse(self._is_cognito_user_enabled(user_id)) + self.assertEqual(StaffUserStatus.INACTIVE.value, self._get_user_record(user_id)['status']) + + def test_deactivate_user_disables_cognito_before_marking_records_inactive(self): + """Order matters: a record marked inactive while the user can still sign in would be flipped + straight back to active by the pre-token hook. Locking Cognito first cannot go wrong that way - + a failure in between just leaves the next sweep to finish the job.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + client = UserClient(self.config) + client.record_user_login(user_id=user_id, compacts=['socw']) + + statuses_when_disabled = [] + with patch('cc_common.config._Config.cognito_client') as mock_cognito_client: + mock_cognito_client.admin_disable_user.side_effect = lambda **_kwargs: statuses_when_disabled.append( + self._get_user_record(user_id)['status'] + ) + client.deactivate_user(user_id=user_id) + + self.assertEqual([StaffUserStatus.ACTIVE.value], statuses_when_disabled) + + def _put_oversized_users(self, count: int, *, compact: str = 'socw') -> set[str]: + """Write `count` deliberately oversized user records, so a GSI query has to paginate. + + DynamoDB caps a query page at 1MB, so ~100KB of padding per record forces LastEvaluatedKey + after a handful of items. The padding is an unknown field, dropped when the record loads. + """ + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + schema = UserRecordSchema() + user_ids = set() + with self.config.users_table.batch_writer() as batch: + for i in range(count): + user_id = str(uuid4()) + user_ids.add(user_id) + record = schema.dump( + { + 'userId': user_id, + 'compact': compact, + 'status': StaffUserStatus.ACTIVE.value, + 'attributes': { + 'email': f'user{i}@example.com', + 'givenName': f'Given{i:04d}', + 'familyName': f'Family{i:04d}', + }, + 'permissions': {'actions': set(), 'jurisdictions': {}}, + } + ) + record['padding'] = 'x' * 100_000 + batch.put_item(Item=record) + return user_ids + + def test_iterate_all_users_in_compact_yields_every_user_across_pages(self): + from boto3.dynamodb.conditions import Key + from cc_common.data_model.user_client import UserClient + + expected_user_ids = self._put_oversized_users(12) + + # Guard the premise of this test: one query page must not be able to hold all of these + # records, or we would not be exercising the pagination loop at all. + single_page = self.config.users_table.query( + IndexName=self.config.fam_giv_index_name, + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('sk').eq('COMPACT#socw'), + ) + self.assertIn('LastEvaluatedKey', single_page) + + users = list(UserClient(self.config).iterate_all_users_in_compact(compact='socw')) + + self.assertEqual(expected_user_ids, {str(user.userId) for user in users}) + + def test_iterate_all_users_in_compact_yields_staff_user_data(self): + from cc_common.data_model.schema.user import StaffUserData + from cc_common.data_model.user_client import UserClient + + self._load_user_data() + + users = list(UserClient(self.config).iterate_all_users_in_compact(compact='socw')) + + self.assertEqual(1, len(users)) + self.assertIsInstance(users[0], StaffUserData) + self.assertEqual('justin@example.org', users[0].email) + + def test_iterate_all_users_in_compact_excludes_other_compacts(self): + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + # A record for the same user in another compact must not come back on a socw query + self.config.users_table.put_item( + Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} + ) + + users = list(UserClient(self.config).iterate_all_users_in_compact(compact='socw')) + + self.assertEqual(['socw'], [user.compact for user in users]) + + def test_iterate_all_users_in_compact_yields_nothing_when_empty(self): + from cc_common.data_model.user_client import UserClient + + self.assertEqual([], list(UserClient(self.config).iterate_all_users_in_compact(compact='socw'))) + def test_get_user_in_compact_not_found(self): """User ID not found should raise an exception""" from cc_common.data_model.user_client import UserClient @@ -187,6 +434,31 @@ def test_update_user_permissions_no_change(self): jurisdiction_action_additions={}, ) + def test_update_user_permissions_additions_refresh_date_of_update(self): + """Permission additions build a raw ADD expression rather than going through + UserRecordSchema.dump, so they do not get the schema's populate_date_of_update hook for free.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + + UserClient(self.config).update_user_permissions( + compact='socw', user_id=user_id, jurisdiction_action_additions={'ky': {'write'}} + ) + + self.assertEqual(MOCK_DATETIME.isoformat(), self._get_user_record(user_id)['dateOfUpdate']) + + def test_update_user_permissions_removals_refresh_date_of_update(self): + """Removals build a separate raw DELETE expression, so they need their own dateOfUpdate set.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + + UserClient(self.config).update_user_permissions( + compact='socw', user_id=user_id, jurisdiction_action_removals={'oh': {'write'}} + ) + + self.assertEqual(MOCK_DATETIME.isoformat(), self._get_user_record(user_id)['dateOfUpdate']) + def test_update_user_attributes(self): # The sample user looks like board staff in socw/oh user_id = UUID(self._load_user_data()) @@ -218,6 +490,17 @@ def test_update_user_attributes_not_found(self): attributes={'givenName': 'Bob', 'familyName': 'Smith'}, ) + def test_update_user_attributes_refreshes_date_of_update(self): + """Attribute updates build a raw SET expression rather than going through UserRecordSchema.dump, + so they do not get the schema's populate_date_of_update hook for free.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + + UserClient(self.config).update_user_attributes(user_id=user_id, attributes={'givenName': 'Changed'}) + + self.assertEqual(MOCK_DATETIME.isoformat(), self._get_user_record(user_id)['dateOfUpdate']) + def test_create_new_user(self): from cc_common.data_model.schema.common import StaffUserStatus from cc_common.data_model.user_client import UserClient @@ -364,6 +647,45 @@ def test_reinvite_existing_user_unexpected_status(self, mock_cognito_client): with self.assertRaises(CCInternalException): client.reinvite_user(email='new_user@example.org') + def test_reinvite_re_enables_a_deactivated_user(self): + """The recovery path for inactivity deactivation: an admin re-invites the user. + + Without the re-enable, the invitation lands but the user still cannot sign in. + """ + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + client = UserClient(self.config) + client.deactivate_user(user_id=user_id) + self.assertFalse(self._is_cognito_user_enabled(user_id)) + + user_data = self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=user_id) + client.reinvite_user(email=self._get_email_from_user_attributes(user_data)) + + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + @patch('cc_common.config._Config.cognito_client') + def test_reinvite_does_not_re_enable_an_enabled_user(self, mock_cognito_client): + from cc_common.data_model.user_client import UserClient + + user_id = str(uuid4()) + mock_cognito_client.admin_get_user.return_value = { + 'Username': user_id, + 'UserAttributes': [ + {'Name': 'email', 'Value': 'new_user@example.org'}, + {'Name': 'email_verified', 'Value': 'True'}, + {'Name': 'sub', 'Value': user_id}, + ], + 'UserCreateDate': datetime(2015, 1, 1, tzinfo=UTC), + 'UserLastModifiedDate': datetime(2015, 1, 1, tzinfo=UTC), + 'Enabled': True, + 'UserStatus': 'FORCE_CHANGE_PASSWORD', + } + + UserClient(self.config).reinvite_user(email='new_user@example.org') + + mock_cognito_client.admin_enable_user.assert_not_called() + def test_reinvite_user_not_found(self): from cc_common.data_model.user_client import UserClient from cc_common.exceptions import CCNotFoundException diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py index bfb0fc46a3..962f858585 100644 --- a/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py @@ -66,3 +66,43 @@ def test_send_provider_home_state_change_email_should_invoke_lambda_client_with_ } ), ) + + def test_send_staff_user_inactivity_notification_email_invokes_lambda_with_expected_parameters(self): + from datetime import date + + from cc_common.email_service_client import StaffUserInactivityNotificationTemplateVariables + + mock_lambda_client = MagicMock() + test_model = self._generate_test_model(mock_lambda_client) + + test_model.send_staff_user_inactivity_notification_email( + compact=TEST_COMPACT, + recipient_emails=['jane@example.com', 'admin@example.com'], + template_variables=StaffUserInactivityNotificationTemplateVariables( + staff_user_first_name='Jane', + staff_user_last_name='Smith', + staff_user_email='jane@example.com', + deactivation_date=date(2026, 9, 14), + inactivity_period_days=60, + ), + ) + + mock_lambda_client.invoke.assert_called_once_with( + FunctionName='test-lambda-name', + InvocationType='RequestResponse', + Payload=json.dumps( + { + 'compact': TEST_COMPACT, + 'template': 'staffUserInactivityNotification', + 'recipientType': 'SPECIFIC', + 'specificEmails': ['jane@example.com', 'admin@example.com'], + 'templateVariables': { + 'staffUserFirstName': 'Jane', + 'staffUserLastName': 'Smith', + 'staffUserEmail': 'jane@example.com', + 'deactivationDate': '2026-09-14', + 'inactivityPeriodDays': 60, + }, + } + ), + ) diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py index 8908b12ef6..45d43a36f6 100644 --- a/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py @@ -4,7 +4,6 @@ from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config -from cc_common.data_model.schema.common import StaffUserStatus from user_data import UserData logger = Logger() @@ -34,23 +33,23 @@ def customize_scopes(event: dict, context: LambdaContext): # noqa: ARG001 unuse user_data = UserData(sub) logger.debug('Adding scopes', scopes=user_data.scopes) - # Get all the user's records and set their status to active - for record in user_data.records: - # Only update the status if it's not already active - if record['status'] != StaffUserStatus.ACTIVE.value: - config.users_table.update_item( - Key={'pk': record['pk'], 'sk': record['sk']}, - UpdateExpression='SET #status = :status', - ExpressionAttributeNames={'#status': 'status'}, - ExpressionAttributeValues={':status': StaffUserStatus.ACTIVE.value}, - ) - # We want to catch almost any exception here, so we can gracefully return execution back to AWS except Exception as e: # noqa: BLE001 broad-exception-caught logger.error('Error while getting user scopes!', exc_info=e) event['response']['claimsAndScopeOverrideDetails'] = None return event + # Mark the user active and stamp lastLoginAt on each of their records. This login timestamp update + # is best attempt, so a failure here is logged and swallowed rather than costing the user the + # scopes we just calculated. + try: + config.user_client.record_user_login( + user_id=sub, + compacts={record['compact'] for record in user_data.records}, + ) + except Exception as e: # noqa: BLE001 broad-exception-caught + logger.error('Error while recording user login!', exc_info=e) + event['response']['claimsAndScopeOverrideDetails'] = { 'accessTokenGeneration': { 'scopesToAdd': list(user_data.scopes), diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py index 5fcfe02e6a..159f5f3f61 100644 --- a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py @@ -1,13 +1,91 @@ import json +from datetime import datetime from unittest.mock import patch from moto import mock_aws from tests import TstLambdas +TEST_LOGIN_TIME = '2024-11-08T23:59:59+00:00' + @mock_aws class TestCustomizeScopes(TstLambdas): + def _put_user_record(self, sub: str, *, status: str, last_login_at: str | None = None): + item = { + 'pk': f'USER#{sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'status': status, + 'permissions': { + 'jurisdictions': { + # should correspond to the 'al/socw.write' scope + 'al': {'write'} + }, + }, + } + if last_login_at is not None: + item['lastLoginAt'] = last_login_at + self._table.put_item(Item=item) + + def _get_user_record(self, sub: str) -> dict: + return self._table.get_item(Key={'pk': f'USER#{sub}', 'sk': 'COMPACT#socw'})['Item'] + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(TEST_LOGIN_TIME)) + def test_records_last_login(self): + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + from cc_common.data_model.schema.common import StaffUserStatus + + self._put_user_record(sub, status=StaffUserStatus.INACTIVE.value) + + customize_scopes(event, self.mock_context) + + self.assertEqual(TEST_LOGIN_TIME, self._get_user_record(sub)['lastLoginAt']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(TEST_LOGIN_TIME)) + def test_refreshes_last_login_for_already_active_user(self): + """The previous implementation skipped the write entirely for active users, which would have + frozen lastLoginAt at the user's first ever sign-in.""" + from cc_common.data_model.schema.common import StaffUserStatus + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + self._put_user_record(sub, status=StaffUserStatus.ACTIVE.value, last_login_at='2024-01-01T00:00:00+00:00') + + customize_scopes(event, self.mock_context) + + self.assertEqual(TEST_LOGIN_TIME, self._get_user_record(sub)['lastLoginAt']) + + @patch('cc_common.data_model.user_client.UserClient.record_user_login') + def test_login_recording_failure_does_not_block_authentication(self, mock_record_user_login): + """Recording the login is bookkeeping - a failure there must never cost the user their scopes.""" + from cc_common.data_model.schema.common import StaffUserStatus + from main import customize_scopes + + mock_record_user_login.side_effect = RuntimeError('Oh noes!') + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + self._put_user_record(sub, status=StaffUserStatus.INACTIVE.value) + + resp = customize_scopes(event, self.mock_context) + + mock_record_user_login.assert_called_once() + self.assertEqual( + sorted(['profile', 'socw/readGeneral', 'al/socw.write']), + sorted(resp['response']['claimsAndScopeOverrideDetails']['accessTokenGeneration']['scopesToAdd']), + ) + def test_happy_path(self): from cc_common.data_model.schema.common import StaffUserStatus from main import customize_scopes diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py new file mode 100644 index 0000000000..3f829175b9 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py @@ -0,0 +1,326 @@ +import os +from dataclasses import dataclass +from datetime import UTC, date, timedelta +from enum import StrEnum + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.user import StaffUserData +from cc_common.email_service_client import StaffUserInactivityNotificationTemplateVariables +from cc_common.exceptions import CCInternalException, CCInvalidRequestException +from staff_user_directory import CompactStaffUserDirectory +from staff_user_inactivity_tracker import InactivityEventType, InactivityStep, StaffUserInactivityTracker + +DAYS_BEFORE_TO_EVENT_TYPE = { + 10: InactivityEventType.TEN_DAY, + 3: InactivityEventType.THREE_DAY, + 1: InactivityEventType.ONE_DAY, + 0: InactivityEventType.DAY_OF, +} + +# Stop and alarm rather than dying mid-user. The next run picks up whatever the tracker shows as unfinished. +TIMEOUT_BUFFER_MS = 60_000 + + +class EmailOutcome(StrEnum): + SENT = 'sent' + ALREADY_DONE = 'alreadyDone' + FAILED = 'failed' + + +@dataclass +class Metrics: + """Counts for one run, returned and logged so a run can be reconciled after the fact.""" + + matched_user_count: int = 0 + user_emails_sent: int = 0 + admin_emails_sent: int = 0 + already_done: int = 0 + emails_failed: int = 0 + no_admin_recipients: int = 0 + deactivated: int = 0 + deactivations_failed: int = 0 + + def record_email_outcome(self, outcome: EmailOutcome, *, step: InactivityStep) -> None: + if outcome == EmailOutcome.ALREADY_DONE: + self.already_done += 1 + elif outcome == EmailOutcome.FAILED: + self.emails_failed += 1 + elif step == InactivityStep.USER_EMAIL: + self.user_emails_sent += 1 + else: + self.admin_emails_sent += 1 + + def as_dict(self) -> dict[str, int]: + return { + 'matchedUsers': self.matched_user_count, + 'userEmailsSent': self.user_emails_sent, + 'adminEmailsSent': self.admin_emails_sent, + 'alreadyDone': self.already_done, + 'emailsFailed': self.emails_failed, + 'noAdminRecipients': self.no_admin_recipients, + 'deactivated': self.deactivated, + 'deactivationsFailed': self.deactivations_failed, + } + + +def process_staff_user_inactivity(event: dict, context: LambdaContext) -> dict: + """Send inactivity reminders to a staff user and their administrators, and deactivate on the day-of + run. The day-of run only deactivates - the 1-day run already sent the last actionable notice, so a + second email at the moment access is revoked would just be noise on top of a decision already made. + + Event format: + { + "compact": "socw", # required + "daysBeforeDeactivation": 10, # required - 10, 3, 1, or 0 + "targetLastLoginDate": "2026-06-21" # optional - replay a specific day's matched_users + } + """ + try: + compact = event['compact'] + days_before = event['daysBeforeDeactivation'] + except KeyError as e: + raise CCInvalidRequestException(f'Missing required field: {e.args[0]}') from None + + if days_before not in DAYS_BEFORE_TO_EVENT_TYPE: + raise CCInvalidRequestException( + f'Invalid daysBeforeDeactivation: {days_before}. Must be one of {sorted(DAYS_BEFORE_TO_EVENT_TYPE)}.' + ) + if compact not in config.compacts: + raise CCInvalidRequestException(f'Invalid compact: {compact}. Must be one of {config.compacts}.') + + inactivity_period_days = int(os.environ['STAFF_USER_INACTIVITY_PERIOD_DAYS']) + # The inactivity period elapses at the end of its final day, so deactivation lands on the next one. + # That way every user keeps their whole final day, in whichever timezone they are in. + days_to_deactivation = inactivity_period_days + 1 + + today = config.current_standard_datetime.astimezone(UTC).date() + target_last_login_date = ( + _parse_iso_date(event['targetLastLoginDate']) + if 'targetLastLoginDate' in event + else today - timedelta(days=days_to_deactivation - days_before) + ) + event_type = DAYS_BEFORE_TO_EVENT_TYPE[days_before] + is_deactivation_run = days_before == 0 + + logger.info( + 'Processing staff user inactivity', + compact=compact, + days_before=days_before, + event_type=event_type, + target_last_login_date=target_last_login_date.isoformat(), + ) + + directory = CompactStaffUserDirectory(compact=compact) + # The deactivation run sweeps everyone at or past the cutoff, so a user missed by a failed run is + # still caught. The reminder runs match one exact day, or they would re-warn every day after it. + matched_users = ( + directory.users_last_seen_on_or_before(target_last_login_date) + if is_deactivation_run + else directory.users_last_seen_on(target_last_login_date) + ) + + metrics = Metrics(matched_user_count=len(matched_users)) + for processed_count, user in enumerate(matched_users): + if context.get_remaining_time_in_millis() < TIMEOUT_BUFFER_MS: + logger.error( + 'Ran out of time processing staff user inactivity', + compact=compact, + event_type=event_type, + processed=processed_count, + remaining=len(matched_users) - processed_count, + metrics=metrics.as_dict(), + ) + raise CCInternalException('Ran out of time processing staff user inactivity') + + _process_user( + user=user, + directory=directory, + event_type=event_type, + days_to_deactivation=days_to_deactivation, + inactivity_period_days=inactivity_period_days, + is_deactivation_run=is_deactivation_run, + metrics=metrics, + ) + + logger.info('Completed staff user inactivity run', compact=compact, metrics=metrics.as_dict()) + return { + 'compact': compact, + 'daysBeforeDeactivation': days_before, + 'targetLastLoginDate': target_last_login_date.isoformat(), + 'metrics': metrics.as_dict(), + } + + +def _process_user( + *, + user: StaffUserData, + directory: CompactStaffUserDirectory, + event_type: InactivityEventType, + days_to_deactivation: int, + inactivity_period_days: int, + is_deactivation_run: bool, + metrics: Metrics, +) -> None: + """Deactivate the user on the day-of run; otherwise send their reminder notice. + + The day-of run never sends a notice of its own - the 1-day run already told this user their account + would be deactivated today, so nothing new is left to say. + """ + last_login_date = user.lastLoginAt.astimezone(UTC).date() + tracker = StaffUserInactivityTracker( + compact=directory.compact, + user_id=str(user.userId), + last_login_date=last_login_date, + event_type=event_type, + ) + + if is_deactivation_run: + _deactivate_user(user=user, tracker=tracker, metrics=metrics) + return + + template_variables = StaffUserInactivityNotificationTemplateVariables( + staff_user_first_name=user.givenName, + staff_user_last_name=user.familyName, + staff_user_email=user.email, + deactivation_date=last_login_date + timedelta(days=days_to_deactivation), + inactivity_period_days=inactivity_period_days, + ) + + # The user and their admins get separate sends, so a partial failure only retries the half that + # failed. + metrics.record_email_outcome( + _send_tracked_email( + tracker=tracker, + step=InactivityStep.USER_EMAIL, + compact=directory.compact, + recipient_emails=[user.email], + template_variables=template_variables, + ), + step=InactivityStep.USER_EMAIL, + ) + + admin_emails = resolve_admin_recipients(user=user, directory=directory) + if not admin_emails: + metrics.no_admin_recipients += 1 + else: + metrics.record_email_outcome( + _send_tracked_email( + tracker=tracker, + step=InactivityStep.ADMIN_EMAIL, + compact=directory.compact, + recipient_emails=sorted(admin_emails), + template_variables=template_variables, + ), + step=InactivityStep.ADMIN_EMAIL, + ) + + +def _send_tracked_email( + *, + tracker: StaffUserInactivityTracker, + step: InactivityStep, + compact: str, + recipient_emails: list[str], + template_variables: StaffUserInactivityNotificationTemplateVariables, +) -> EmailOutcome: + """Send one notification, honouring and updating the idempotency tracker.""" + if tracker.was_already_done(step): + return EmailOutcome.ALREADY_DONE + + try: + config.email_service_client.send_staff_user_inactivity_notification_email( + compact=compact, + recipient_emails=recipient_emails, + template_variables=template_variables, + ) + except Exception as e: # noqa: BLE001 one failed send must not impact the remaining users + tracker.record_failure(step, error_message=str(e)) + logger.error( + 'Failed to send staff user inactivity notification', + compact=compact, + step=step, + error=str(e), + ) + return EmailOutcome.FAILED + + tracker.record_success(step) + return EmailOutcome.SENT + + +def _deactivate_user(*, user: StaffUserData, tracker: StaffUserInactivityTracker, metrics: Metrics) -> None: + if tracker.was_already_done(InactivityStep.DEACTIVATION): + metrics.already_done += 1 + return + + try: + config.user_client.deactivate_user(user_id=str(user.userId)) + except Exception as e: # noqa: BLE001 one failed deactivation must not impact the remaining users + tracker.record_failure(InactivityStep.DEACTIVATION, error_message=str(e)) + logger.error('Failed to deactivate staff user', user_id=str(user.userId), error=str(e)) + metrics.deactivations_failed += 1 + return + + tracker.record_success(InactivityStep.DEACTIVATION) + metrics.deactivated += 1 + + +def _parse_iso_date(value: str) -> date: + try: + return date.fromisoformat(value) + except ValueError as e: + raise CCInvalidRequestException(f'Invalid ISO date for targetLastLoginDate: {value}') from e + + +def resolve_admin_recipients(*, user: StaffUserData, directory: CompactStaffUserDirectory) -> set[str]: + """Resolve the admin email addresses to notify about this user's pending deactivation. + + - Users with permissions in a specific state -> that state's admins. + - Compact-level permissions, OR the user is the state's only admin, OR the state has no + admins -> compact admins. + + The user being deactivated is always excluded; they receive their own copy. + """ + # Any compact-level action (admin, readPrivate) escalates to the compact admins + notify_compact_admins = bool(user.compactActions) + + admin_emails: set[str] = set() + for jurisdiction in user.jurisdictions: + others = _notifiable_admins(directory.jurisdiction_admins(jurisdiction), excluding=user) + if not others: + # Covers both "the state has no admins" and "the user is the state's only admin" - once the + # user is excluded, those are the same condition + notify_compact_admins = True + else: + admin_emails.update(admin.email for admin in others) + + if not user.jurisdictions: + # A user with no jurisdiction permissions at all is a compact-only user + notify_compact_admins = True + + if notify_compact_admins: + admin_emails.update(admin.email for admin in _notifiable_admins(directory.compact_admins, excluding=user)) + + if not admin_emails: + logger.error( + 'No admins found to notify about staff user deactivation', + compact=directory.compact, + user_id=str(user.userId), + ) + + return admin_emails + + +def _notifiable_admins(admins: list[StaffUserData], *, excluding: StaffUserData) -> list[StaffUserData]: + """The admins from this list who are worth notifying. + + The directory returns admins whatever their status, since other callers may want them all. Here we + want only admins who can act: an inactive admin cannot sign in, and counting one would suppress + escalation to the compact admins for a state whose only admin is inactive. + + The user being deactivated is excluded too - they receive their own copy. + """ + return [ + admin for admin in admins if admin.userId != excluding.userId and admin.status == StaffUserStatus.ACTIVE.value + ] diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py new file mode 100644 index 0000000000..a850d1bbb2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py @@ -0,0 +1,66 @@ +from collections import defaultdict +from collections.abc import Generator +from datetime import UTC, date + +from cc_common.config import config, logger +from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.user import StaffUserData + + +class CompactStaffUserDirectory: + """All staff users in a compact, loaded in a single pass over the famGiv GSI. + + Built once and queried repeatedly. The admin buckets are classified eagerly at construction; + matching is a query against the loaded set, so this class is usable by anything that + needs to reach a compact's staff users or their admins. + """ + + def __init__(self, *, compact: str): + self.compact = compact + self._users: list[StaffUserData] = [] + self._compact_admins: list[StaffUserData] = [] + self._jurisdiction_admins: dict[str, list[StaffUserData]] = defaultdict(list) + self._load() + + def _load(self) -> None: + for user in config.user_client.iterate_all_users_in_compact(compact=self.compact): + self._users.append(user) + if user.isCompactAdmin: + self._compact_admins.append(user) + for jurisdiction in user.jurisdictions: + if user.isJurisdictionAdmin(jurisdiction): + self._jurisdiction_admins[jurisdiction].append(user) + + logger.info('Loaded staff user directory', compact=self.compact, user_count=len(self._users)) + + def users_last_seen_on(self, last_login_date: date) -> list[StaffUserData]: + """Active users whose last sign-in was on this date.""" + return [user for user in self._candidates() if self._last_login_date(user) == last_login_date] + + def users_last_seen_on_or_before(self, last_login_date: date) -> list[StaffUserData]: + """Active users whose last sign-in was on or before this date.""" + return [user for user in self._candidates() if self._last_login_date(user) <= last_login_date] + + def jurisdiction_admins(self, jurisdiction: str) -> list[StaffUserData]: + """Users holding the admin action in this jurisdiction, whatever their status.""" + return list(self._jurisdiction_admins[jurisdiction]) + + @property + def compact_admins(self) -> list[StaffUserData]: + """Users holding the admin action at the compact level, whatever their status.""" + return list(self._compact_admins) + + def _candidates(self) -> Generator[StaffUserData, None, None]: + """Users eligible to be matched for inactivity processing. + + Users who are already inactive have been deactivated, and users with no lastLoginAt have not + signed in, so neither has an inactivity clock running. + """ + return ( + user for user in self._users if user.status == StaffUserStatus.ACTIVE.value and user.lastLoginAt is not None + ) + + @staticmethod + def _last_login_date(user: StaffUserData) -> date: + # Timestamps are written in UTC, but normalize so the comparison cannot drift on an offset + return user.lastLoginAt.astimezone(UTC).date() diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py new file mode 100644 index 0000000000..3e2e79f401 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py @@ -0,0 +1,126 @@ +"""Idempotency tracking for staff user inactivity notifications and deactivations.""" + +import time +from datetime import date, timedelta +from enum import StrEnum + +from cc_common.config import config, logger + + +class InactivityEventType(StrEnum): + """Which of the scheduled runs an attempt belongs to.""" + + TEN_DAY = 'staffUser.inactivity.10day' + THREE_DAY = 'staffUser.inactivity.3day' + ONE_DAY = 'staffUser.inactivity.1day' + DAY_OF = 'staffUser.inactivity.dayOf' + + +class InactivityStep(StrEnum): + """The independently tracked steps of one user's inactivity event. + + Each is recorded separately so a partial failure only retries the part that failed. + """ + + USER_EMAIL = 'USER' + ADMIN_EMAIL = 'ADMINS' + DEACTIVATION = 'DEACTIVATION' + + +class StaffUserInactivityTracker: + """Tracks which inactivity steps have already been completed for one staff user. + + Keyed on the user's own lastLoginAt date rather than the run's target date, so a user's key is + stable across consecutive day-of sweeps. + + Key pattern: + pk: {compact}#STAFF_USER_INACTIVITY#{user_id} + sk: {event_type}#{last_login_date}#{step} + ttl: 90 days after the record is written + """ + + _TTL_DAYS = 90 + _SUCCESS_STATUS = 'SUCCESS' + _FAILED_STATUS = 'FAILED' + + def __init__( + self, + *, + compact: str, + user_id: str, + last_login_date: date, + event_type: InactivityEventType, + ): + self.compact = compact + self.user_id = user_id + self.last_login_date = last_login_date + self.event_type = event_type + # One query up front, rather than a read per step + self._attempts = self._load_attempts() + + def was_already_done(self, step: InactivityStep) -> bool: + """Whether this step already completed successfully.""" + return self._attempts.get(self._build_sk(step), {}).get('status') == self._SUCCESS_STATUS + + def record_success(self, step: InactivityStep) -> None: + """Record that this step completed.""" + self._write_record(step, status=self._SUCCESS_STATUS) + + def record_failure(self, step: InactivityStep, *, error_message: str) -> None: + """Record that this step failed, so it will be retried.""" + self._write_record(step, status=self._FAILED_STATUS, error_message=error_message) + + def _build_pk(self) -> str: + return f'{self.compact}#STAFF_USER_INACTIVITY#{self.user_id}' + + def _build_sk(self, step: InactivityStep) -> str: + return f'{self.event_type}#{self.last_login_date.isoformat()}#{step}' + + def _load_attempts(self) -> dict[str, dict]: + try: + response = config.event_state_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': self._build_pk()}, + ConsistentRead=True, + ) + return {item['sk']: item for item in response.get('Items', [])} + except Exception as e: # noqa: BLE001 any read failure should fail open + # Fail open: a duplicate email is a better outcome than a missed deactivation + logger.warning('Failed to read staff user inactivity state', **self._log_context(), error=str(e)) + return {} + + def _write_record(self, step: InactivityStep, *, status: str, error_message: str | None = None) -> None: + item = { + 'pk': self._build_pk(), + 'sk': self._build_sk(step), + 'status': status, + 'compact': self.compact, + 'userId': self.user_id, + 'lastLoginDate': self.last_login_date.isoformat(), + 'eventType': self.event_type, + 'step': step, + 'ttl': int(time.time()) + int(timedelta(days=self._TTL_DAYS).total_seconds()), + } + if error_message: + item['errorMessage'] = error_message + + try: + config.event_state_table.put_item(Item=item) + self._attempts[item['sk']] = item + except Exception as e: # noqa: BLE001 tracking is secondary to the work it describes + # The work this describes has already happened, so swallow and let the next run reconcile + logger.error( + 'Unable to record staff user inactivity state', + status=status, + step=step, + **self._log_context(), + error=str(e), + ) + + def _log_context(self) -> dict: + return { + 'compact': self.compact, + 'user_id': self.user_id, + 'last_login_date': self.last_login_date.isoformat(), + 'event_type': self.event_type, + } diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py index 88710db094..29dbfffd15 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py @@ -17,6 +17,9 @@ def setUpClass(cls): 'USER_POOL_ID': 'us-east-1-12345', 'USERS_TABLE_NAME': 'provider-table', 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'EVENT_STATE_TABLE_NAME': 'event-state-table', + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': 'email-notification-service', + 'STAFF_USER_INACTIVITY_PERIOD_DAYS': '60', 'FAM_GIV_INDEX_NAME': 'famGiv', 'COMPACTS': '["socw"]', 'JURISDICTIONS': '["ne", "oh", "ky"]', diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py index ec40e13d67..f5548375f1 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py @@ -49,6 +49,7 @@ def build_resources(self): ], ) self.create_compact_configuration_table() + self.create_event_state_table() # Adding a waiter allows for testing against an actual AWS account, if needed waiter = self._table.meta.client.get_waiter('table_exists') waiter.wait(TableName=self._table.name) @@ -64,6 +65,18 @@ def build_resources(self): os.environ['USER_POOL_ID'] = user_pool_response['UserPool']['Id'] self._user_pool_id = user_pool_response['UserPool']['Id'] + def create_event_state_table(self): + """Create the event state table, which tracks what work has already been done for an event.""" + self._event_state_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['EVENT_STATE_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + def create_compact_configuration_table(self): """Create the compact configuration table for testing.""" self._compact_configuration_table = boto3.resource('dynamodb').create_table( @@ -82,6 +95,7 @@ def create_compact_configuration_table(self): def delete_resources(self): self._table.delete() self._compact_configuration_table.delete() + self._event_state_table.delete() waiter = self._table.meta.client.get_waiter('table_not_exists') waiter.wait(TableName=self._table.name) # Delete the Cognito user pool diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py index 54397e4fa6..58de2f4a03 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py @@ -87,3 +87,53 @@ def test_get_users_paginated(self): second_users = body['users'] self.assertEqual(5, len(second_users)) self.assertIsNone(body['pagination']['lastKey']) + + def test_get_users_returns_last_login_at(self): + """A stored lastLoginAt must survive the record -> API schema transform. + + UserAPISchema raises on unknown fields, so a field present on the record but undeclared + there would fail validation for every staff user this endpoint returns. + """ + last_login_at = '2024-09-12T12:34:56+00:00' + user_id = self._create_compact_staff_user(compacts=['socw']) + self._table.update_item( + Key={'pk': f'USER#{user_id}', 'sk': 'COMPACT#socw'}, + UpdateExpression='SET lastLoginAt = :lastLoginAt', + ExpressionAttributeValues={':lastLoginAt': last_login_at}, + ) + + from handlers.users import get_users + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = None + + resp = get_users(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + users = json.loads(resp['body'])['users'] + self.assertEqual(1, len(users)) + self.assertEqual(last_login_at, users[0]['lastLoginAt']) + + def test_get_users_omits_absent_last_login_at(self): + """Users who have never signed in simply have no lastLoginAt -- not a null.""" + self._create_compact_staff_user(compacts=['socw']) + + from handlers.users import get_users + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = None + + resp = get_users(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + users = json.loads(resp['body'])['users'] + self.assertEqual(1, len(users)) + self.assertNotIn('lastLoginAt', users[0]) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py index fdcf5935fd..09c7d121b1 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py @@ -1,11 +1,17 @@ import json +from datetime import datetime +from unittest.mock import patch from moto import mock_aws from .. import TstFunction +# Frozen "now" for the whole class, so writes that refresh dateOfUpdate land on an assertable value +MOCK_DATETIME = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + @mock_aws +@patch('cc_common.config._Config.current_standard_datetime', MOCK_DATETIME) class TestPatchMe(TstFunction): def test_patch_me_not_found(self): from handlers.me import patch_me @@ -44,6 +50,8 @@ def test_patch_me(self): with open('tests/resources/api/user-response.json') as f: expected_user = json.load(f) expected_user['attributes']['givenName'] = 'George' + # The patch refreshes dateOfUpdate, so it no longer matches the fixture's original value + expected_user['dateOfUpdate'] = MOCK_DATETIME.isoformat() body = json.loads(resp['body']) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py index d066cee026..f06d203b01 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py @@ -1,11 +1,17 @@ import json +from datetime import datetime +from unittest.mock import patch from moto import mock_aws from .. import TstFunction +# Frozen "now" for the whole class, so writes that refresh dateOfUpdate land on an assertable value +MOCK_DATETIME = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + @mock_aws +@patch('cc_common.config._Config.current_standard_datetime', MOCK_DATETIME) class TestPatchUser(TstFunction): def _when_testing_with_valid_jurisdiction(self, compact: str): # load oh jurisdiction for provided compact to pass the jurisdiction validation @@ -35,7 +41,8 @@ def test_patch_user(self): self.assertEqual( { 'attributes': {'email': 'justin@example.org', 'familyName': 'Williams', 'givenName': 'Justin'}, - 'dateOfUpdate': '2024-09-12T23:59:59+00:00', + # Refreshed by the patch, so no longer the fixture's original value + 'dateOfUpdate': MOCK_DATETIME.isoformat(), 'status': StaffUserStatus.INACTIVE.value, 'permissions': { 'socw': { @@ -96,9 +103,6 @@ def test_patch_user_document_path_overlap(self): self.assertEqual(200, resp['statusCode']) user = json.loads(resp['body']) - # Don't compare the dateOfUpdate in comparison, since its value is dynamic - del user['dateOfUpdate'] - self.assertEqual( { 'attributes': { @@ -106,6 +110,8 @@ def test_patch_user_document_path_overlap(self): 'familyName': 'User', 'givenName': 'Test', }, + # Refreshed by the patch, so no longer the seeded record's original value + 'dateOfUpdate': MOCK_DATETIME.isoformat(), 'permissions': { 'socw': { 'actions': {'read': True}, @@ -155,12 +161,12 @@ def test_patch_user_add_to_empty_actions(self): self.assertEqual(200, resp['statusCode']) user = json.loads(resp['body']) - # Drop backend-generated fields from comparison + # userId is backend-generated, so it cannot be compared against the request body del user['userId'] - del user['dateOfUpdate'] - # Add status to the comparison + # Add the fields the response carries that the request body does not api_user['status'] = StaffUserStatus.INACTIVE.value + api_user['dateOfUpdate'] = MOCK_DATETIME.isoformat() self.assertEqual(api_user, user) @@ -198,12 +204,12 @@ def test_patch_user_remove_all_actions(self): self.assertEqual(200, resp['statusCode']) user = json.loads(resp['body']) - # Drop backend-generated fields from comparison + # userId is backend-generated, so it cannot be compared against the request body del user['userId'] - del user['dateOfUpdate'] - # Add status to the comparison + # Add the fields the response carries that the request body does not api_user['status'] = StaffUserStatus.INACTIVE.value + api_user['dateOfUpdate'] = MOCK_DATETIME.isoformat() api_user['permissions'] = {'socw': {'jurisdictions': {}}} self.assertEqual(api_user, user) @@ -305,7 +311,8 @@ def test_patch_user_allows_adding_read_private_permission(self): self.assertEqual( { 'attributes': {'email': 'justin@example.org', 'familyName': 'Williams', 'givenName': 'Justin'}, - 'dateOfUpdate': '2024-09-12T23:59:59+00:00', + # Refreshed by the patch, so no longer the fixture's original value + 'dateOfUpdate': MOCK_DATETIME.isoformat(), 'status': StaffUserStatus.INACTIVE.value, 'permissions': { 'socw': { diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py new file mode 100644 index 0000000000..92f0ca9389 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py @@ -0,0 +1,336 @@ +from datetime import datetime, timedelta +from unittest.mock import patch +from uuid import uuid4 + +from moto import mock_aws + +from .. import TstFunction + +# The run's "today". Every seeded lastLoginAt is expressed as an offset back from this. +MOCK_TODAY = datetime.fromisoformat('2026-09-14T12:00:00+00:00') +COMPACT = 'socw' +ADMIN = 'admin' +WRITE = 'write' + + +@mock_aws +class TestStaffUserInactivity(TstFunction): + def setUp(self): + super().setUp() + # A real Lambda context reports its remaining time; the handler bails out when it runs low + self.mock_context.get_remaining_time_in_millis.return_value = 900_000 + + def _seed_user(self, *, days_since_login: int | None, compact_actions=None, jurisdictions=None, status=None): + """Create a staff user, in Cognito and in the table, last seen `days_since_login` days before MOCK_TODAY.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + email = f'{uuid4()}@example.com' + user_id = self._create_cognito_user(email=email) + record = { + 'userId': user_id, + 'compact': COMPACT, + 'status': status or StaffUserStatus.ACTIVE.value, + 'attributes': {'email': email, 'givenName': 'Given', 'familyName': 'Family'}, + 'permissions': {'actions': compact_actions or set(), 'jurisdictions': jurisdictions or {}}, + } + if days_since_login is not None: + record['lastLoginAt'] = MOCK_TODAY - timedelta(days=days_since_login) + self._table.put_item(Item=UserRecordSchema().dump(record)) + return user_id, email + + def _run(self, *, days_before: int, **event_overrides): + from handlers.staff_user_inactivity import process_staff_user_inactivity + + event = {'compact': COMPACT, 'daysBeforeDeactivation': days_before, **event_overrides} + with patch('cc_common.config._Config.current_standard_datetime', MOCK_TODAY): + return process_staff_user_inactivity(event, self.mock_context) + + @staticmethod + def _sent_payloads(mock_email_client): + return [call.kwargs for call in mock_email_client.send_staff_user_inactivity_notification_email.call_args_list] + + def _recipient_sets(self, mock_email_client): + return [payload['recipient_emails'] for payload in self._sent_payloads(mock_email_client)] + + def _user_status(self, user_id: str) -> str: + return self._table.get_item(Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{COMPACT}'})['Item']['status'] + + def _is_cognito_user_enabled(self, user_id: str) -> bool: + return self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=user_id)[ + 'Enabled' + ] + + # -- event validation -- + + def test_missing_required_fields_raise(self): + """Each case must be rejected for the field that is actually missing, not just rejected.""" + from cc_common.exceptions import CCInvalidRequestException + from handlers.staff_user_inactivity import process_staff_user_inactivity + + cases = ( + ({'daysBeforeDeactivation': 10}, 'compact'), + ({'compact': COMPACT}, 'daysBeforeDeactivation'), + ) + for event, missing_field in cases: + with self.subTest(event=event), self.assertRaises(CCInvalidRequestException) as ctx: + process_staff_user_inactivity(event, self.mock_context) + self.assertEqual(f'Missing required field: {missing_field}', ctx.exception.message) + + def test_invalid_days_before_raises(self): + from cc_common.exceptions import CCInvalidRequestException + + with self.assertRaises(CCInvalidRequestException) as ctx: + self._run(days_before=7) + + self.assertEqual( + 'Invalid daysBeforeDeactivation: 7. Must be one of [0, 1, 3, 10].', + ctx.exception.message, + ) + + def test_invalid_compact_raises(self): + from cc_common.exceptions import CCInvalidRequestException + from handlers.staff_user_inactivity import process_staff_user_inactivity + + with self.assertRaises(CCInvalidRequestException) as ctx: + process_staff_user_inactivity({'compact': 'not-a-compact', 'daysBeforeDeactivation': 10}, self.mock_context) + + self.assertEqual( + f'Invalid compact: not-a-compact. Must be one of {self.config.compacts}.', + ctx.exception.message, + ) + + def test_each_reminder_run_targets_its_own_day(self): + """Under the default 60-day-inactivity threshold, 10-day fires at 51 days since login, 3-day at 58, + 1-day at 60. All exact, not ranges.""" + expected_email_by_run = { + 10: self._seed_user(days_since_login=51)[1], + 3: self._seed_user(days_since_login=58)[1], + 1: self._seed_user(days_since_login=60)[1], + } + # Neighbours on either side of every boundary + for days in (50, 52, 57, 59, 61): + self._seed_user(days_since_login=days) + + for days_before, expected_email in expected_email_by_run.items(): + with ( + self.subTest(days_before=days_before), + patch('cc_common.config._Config.email_service_client') as mock_email_client, + ): + self._run(days_before=days_before) + self.assertEqual([[expected_email]], self._recipient_sets(mock_email_client)) + + def test_one_day_run_notifies_on_the_last_usable_day(self): + """The 1-day notice lands on day 60 - the last day the user can sign in and stop this.""" + user_id, user_email = self._seed_user(days_since_login=60) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + result = self._run(days_before=1) + + self.assertEqual(1, result['metrics']['matchedUsers']) + self.assertEqual([[user_email]], self._recipient_sets(mock_email_client)) + self.assertEqual(0, result['metrics']['deactivated']) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + def test_one_day_notice_says_deactivation_is_tomorrow(self): + """On the final usable day the notice must point at the following day, not today.""" + self._seed_user(days_since_login=60) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=1) + + template_variables = self._sent_payloads(mock_email_client)[0]['template_variables'] + self.assertEqual((MOCK_TODAY + timedelta(days=1)).date(), template_variables.deactivation_date) + + def test_user_last_seen_exactly_60_days_ago_is_not_deactivated(self): + """The user keeps the whole of day 60 - the day-of run does not reach back that far.""" + user_id, _ = self._seed_user(days_since_login=60) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(0, result['metrics']['matchedUsers']) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + def test_day_of_run_sweeps_older_users(self): + """A straggler missed by earlier runs is still caught.""" + self._seed_user(days_since_login=61) + self._seed_user(days_since_login=75) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(2, result['metrics']['matchedUsers']) + + def test_reminder_run_does_not_sweep_older_users(self): + self._seed_user(days_since_login=75) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=10) + + self.assertEqual(0, result['metrics']['matchedUsers']) + + def test_users_who_never_signed_in_are_skipped(self): + self._seed_user(days_since_login=None) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(0, result['metrics']['matchedUsers']) + + def test_already_deactivated_users_are_not_swept_again(self): + from cc_common.data_model.schema.common import StaffUserStatus + + self._seed_user(days_since_login=61, status=StaffUserStatus.INACTIVE.value) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(0, result['metrics']['matchedUsers']) + + def test_target_last_login_date_overrides_the_computed_date(self): + """In the case of a failure that needs to be manually replayed, verify that the replay date is used in place + of the current date. + """ + self._seed_user(days_since_login=40) + replay_date = (MOCK_TODAY - timedelta(days=40)).date().isoformat() + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=10, targetLastLoginDate=replay_date) + + self.assertEqual(1, result['metrics']['matchedUsers']) + + # -- notifications -- + + def test_sends_separate_emails_to_the_user_and_their_admins(self): + _, user_email = self._seed_user(days_since_login=51, jurisdictions={'oh': {WRITE}}) + _, admin_email = self._seed_user(days_since_login=1, jurisdictions={'oh': {ADMIN}}) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=10) + + self.assertEqual([[user_email], [admin_email]], self._recipient_sets(mock_email_client)) + + def test_deactivation_date_is_the_users_own_day_61(self): + self._seed_user(days_since_login=51) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=10) + + template_variables = self._sent_payloads(mock_email_client)[0]['template_variables'] + self.assertEqual((MOCK_TODAY + timedelta(days=10)).date(), template_variables.deactivation_date) + self.assertEqual(60, template_variables.inactivity_period_days) + + def test_already_notified_users_are_not_emailed_twice(self): + self._seed_user(days_since_login=51) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=10) + sends_after_first_run = len(self._sent_payloads(mock_email_client)) + result = self._run(days_before=10) + + self.assertEqual(sends_after_first_run, len(self._sent_payloads(mock_email_client))) + + self.assertEqual(0, result['metrics']['userEmailsSent']) + self.assertGreater(result['metrics']['alreadyDone'], 0) + + def test_an_email_failure_is_counted_and_does_not_abort_the_run(self): + self._seed_user(days_since_login=60) + self._seed_user(days_since_login=60) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + mock_email_client.send_staff_user_inactivity_notification_email.side_effect = RuntimeError('SES is down') + result = self._run(days_before=1) + + self.assertEqual(2, result['metrics']['matchedUsers']) + self.assertEqual(2, result['metrics']['emailsFailed']) + + def test_no_admin_recipients_is_counted(self): + _, user_email = self._seed_user(days_since_login=51, jurisdictions={'oh': {WRITE}}) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + result = self._run(days_before=10) + + self.assertEqual(1, result['metrics']['noAdminRecipients']) + # The user still gets their own copy + self.assertEqual([[user_email]], self._recipient_sets(mock_email_client)) + + # -- deactivation -- + + def test_day_of_run_deactivates_without_sending_a_notice(self): + """The 1-day run already sent the last actionable notice, so day-of only deactivates.""" + from cc_common.data_model.schema.common import StaffUserStatus + + user_id, _ = self._seed_user(days_since_login=61) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + result = self._run(days_before=0) + + mock_email_client.send_staff_user_inactivity_notification_email.assert_not_called() + + self.assertEqual(StaffUserStatus.INACTIVE.value, self._user_status(user_id)) + self.assertFalse(self._is_cognito_user_enabled(user_id)) + self.assertEqual(1, result['metrics']['deactivated']) + self.assertEqual(0, result['metrics']['userEmailsSent']) + self.assertEqual(0, result['metrics']['adminEmailsSent']) + self.assertEqual(0, result['metrics']['noAdminRecipients']) + + def test_day_of_run_sends_no_notice_even_for_a_straggler(self): + """A user 75 days dormant was missed by the 1-day run, but day-of still does not email them.""" + self._seed_user(days_since_login=75) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=0) + + mock_email_client.send_staff_user_inactivity_notification_email.assert_not_called() + + def test_reminder_runs_deactivate_nobody(self): + from cc_common.data_model.schema.common import StaffUserStatus + + user_id, _ = self._seed_user(days_since_login=51) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=10) + + self.assertEqual(0, result['metrics']['deactivated']) + self.assertEqual(StaffUserStatus.ACTIVE.value, self._user_status(user_id)) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + def test_a_sole_compact_admin_is_deactivated_like_anyone_else(self): + """Deliberately uniform: recovery from a compact locking itself out is a console operation.""" + from cc_common.data_model.schema.common import StaffUserStatus + + user_id, _ = self._seed_user(days_since_login=61, compact_actions={ADMIN}) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(1, result['metrics']['deactivated']) + self.assertEqual(StaffUserStatus.INACTIVE.value, self._user_status(user_id)) + + def test_a_deactivation_failure_is_counted_and_does_not_abort_the_run(self): + self._seed_user(days_since_login=61) + self._seed_user(days_since_login=61) + + with ( + patch('cc_common.config._Config.email_service_client'), + patch( + 'cc_common.data_model.user_client.UserClient.deactivate_user', + side_effect=RuntimeError('Cognito is down'), + ), + ): + result = self._run(days_before=0) + + self.assertEqual(2, result['metrics']['deactivationsFailed']) + self.assertEqual(0, result['metrics']['deactivated']) + + # -- timeout guard -- + + def test_raises_when_it_runs_out_of_time(self): + from cc_common.exceptions import CCInternalException + + self._seed_user(days_since_login=61) + self.mock_context.get_remaining_time_in_millis.return_value = 1_000 + + with patch('cc_common.config._Config.email_service_client'), self.assertRaises(CCInternalException): + self._run(days_before=0) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py new file mode 100644 index 0000000000..bc9e2b68f6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py @@ -0,0 +1,134 @@ +from datetime import date +from unittest.mock import patch +from uuid import uuid4 + +from moto import mock_aws + +from . import TstFunction + +LAST_LOGIN_DATE = date(2024, 11, 8) + + +@mock_aws +class TestStaffUserInactivityTracker(TstFunction): + @staticmethod + def _tracker(*, user_id: str, event_type=None, last_login_date: date = LAST_LOGIN_DATE): + from staff_user_inactivity_tracker import InactivityEventType, StaffUserInactivityTracker + + return StaffUserInactivityTracker( + compact='socw', + user_id=user_id, + last_login_date=last_login_date, + event_type=event_type or InactivityEventType.TEN_DAY, + ) + + def test_not_done_until_recorded(self): + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + + self.assertFalse(self._tracker(user_id=user_id).was_already_done(InactivityStep.USER_EMAIL)) + + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + self.assertTrue(self._tracker(user_id=user_id).was_already_done(InactivityStep.USER_EMAIL)) + + def test_steps_are_tracked_independently(self): + """A failure sending the admin email must not suppress the deactivation, or vice versa.""" + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + tracker = self._tracker(user_id=user_id) + self.assertTrue(tracker.was_already_done(InactivityStep.USER_EMAIL)) + self.assertFalse(tracker.was_already_done(InactivityStep.ADMIN_EMAIL)) + self.assertFalse(tracker.was_already_done(InactivityStep.DEACTIVATION)) + + def test_event_types_are_tracked_independently(self): + """The 3-day reminder must still go out to a user who already had the 10-day one.""" + from staff_user_inactivity_tracker import InactivityEventType, InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id, event_type=InactivityEventType.TEN_DAY).record_success(InactivityStep.USER_EMAIL) + + self.assertFalse( + self._tracker(user_id=user_id, event_type=InactivityEventType.THREE_DAY).was_already_done( + InactivityStep.USER_EMAIL + ) + ) + for event_type in (InactivityEventType.ONE_DAY, InactivityEventType.DAY_OF): + with self.subTest(event_type=event_type): + self.assertFalse( + self._tracker(user_id=user_id, event_type=event_type).was_already_done(InactivityStep.USER_EMAIL) + ) + + def test_users_are_tracked_independently(self): + from staff_user_inactivity_tracker import InactivityStep + + recorded_user_id = str(uuid4()) + self._tracker(user_id=recorded_user_id).record_success(InactivityStep.USER_EMAIL) + + self.assertFalse(self._tracker(user_id=str(uuid4())).was_already_done(InactivityStep.USER_EMAIL)) + + def test_a_new_last_login_date_is_a_new_key(self): + """If the user signs in and later goes dormant again, the earlier record must not suppress the new one.""" + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id, last_login_date=date(2024, 11, 8)).record_success(InactivityStep.USER_EMAIL) + + self.assertFalse( + self._tracker(user_id=user_id, last_login_date=date(2025, 3, 1)).was_already_done(InactivityStep.USER_EMAIL) + ) + + def test_record_failure_leaves_the_step_outstanding(self): + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + + self._tracker(user_id=user_id).record_failure(InactivityStep.USER_EMAIL, error_message='SES exploded') + + self.assertFalse(self._tracker(user_id=user_id).was_already_done(InactivityStep.USER_EMAIL)) + stored = self._event_state_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': f'socw#STAFF_USER_INACTIVITY#{user_id}'}, + )['Items'] + self.assertEqual(1, len(stored)) + self.assertEqual('FAILED', stored[0]['status']) + self.assertEqual('SES exploded', stored[0]['errorMessage']) + + def test_records_carry_a_ttl(self): + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + stored = self._event_state_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': f'socw#STAFF_USER_INACTIVITY#{user_id}'}, + )['Items'] + self.assertIn('ttl', stored[0]) + + def test_read_failure_fails_open(self): + """A duplicate email is a better outcome than a missed deactivation.""" + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + with patch('cc_common.config._Config.event_state_table') as mock_table: + mock_table.query.side_effect = RuntimeError('DynamoDB is having a day') + tracker = self._tracker(user_id=user_id) + + self.assertFalse(tracker.was_already_done(InactivityStep.USER_EMAIL)) + + def test_write_failure_does_not_raise(self): + """Tracking is secondary - losing a write must not fail the run that already did the work.""" + from staff_user_inactivity_tracker import InactivityStep + + tracker = self._tracker(user_id=str(uuid4())) + + with patch('cc_common.config._Config.event_state_table') as mock_table: + mock_table.put_item.side_effect = RuntimeError('DynamoDB is having a day') + tracker.record_success(InactivityStep.USER_EMAIL) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/staff_user_test_data.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/staff_user_test_data.py new file mode 100644 index 0000000000..953c47c648 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/staff_user_test_data.py @@ -0,0 +1,61 @@ +"""Helpers for building staff users and directories in unit tests. + +Imports of cc_common are deferred into the functions so that the test base class can monkey-patch the +config object from its environment before anything reads it. +""" + +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +DEFAULT_LAST_LOGIN_AT = '2024-11-08T12:00:00+00:00' +ITERATE_USERS = 'cc_common.data_model.user_client.UserClient.iterate_all_users_in_compact' + + +def build_staff_user( + *, + last_login_at: str | None = DEFAULT_LAST_LOGIN_AT, + status: str | None = None, + compact_actions: set | None = None, + jurisdictions: dict | None = None, +): + """Build a StaffUserData. Pass last_login_at=None for a user who has never signed in.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user import StaffUserData + from common_test.test_constants import DEFAULT_FAMILY_NAME, DEFAULT_GIVEN_NAME + from common_test.test_data_generator import TestDataGenerator + + user_id = str(uuid4()) + staff_user = TestDataGenerator.generate_default_staff_user( + { + 'userId': user_id, + 'status': status or StaffUserStatus.ACTIVE.value, + 'lastLoginAt': datetime.fromisoformat(last_login_at or DEFAULT_LAST_LOGIN_AT), + # Each user needs a distinct email, or assertions on recipient sets pass no matter which + # users the code picked + 'attributes': { + 'email': f'{user_id}@example.com', + 'givenName': DEFAULT_GIVEN_NAME, + 'familyName': DEFAULT_FAMILY_NAME, + }, + 'permissions': { + 'actions': compact_actions or set(), + 'jurisdictions': jurisdictions or {}, + }, + } + ) + if last_login_at is not None: + return staff_user + + # A user who has not signed in since login tracking was introduced has no lastLoginAt at all + record = staff_user.serialize_to_database_record() + del record['lastLoginAt'] + return StaffUserData.from_database_record(record) + + +def build_directory(users, *, compact: str = 'socw'): + """Build a CompactStaffUserDirectory over the given users, without touching DynamoDB.""" + from staff_user_directory import CompactStaffUserDirectory + + with patch(ITERATE_USERS, return_value=iter(users)): + return CompactStaffUserDirectory(compact=compact) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py index 875bd2a9a4..9cfe21b541 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py @@ -1,10 +1,13 @@ import json +from datetime import datetime from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import TypeDeserializer from marshmallow import ValidationError from tests import TstLambdas +TEST_LAST_LOGIN_AT = '2024-09-12T12:34:56+00:00' + class TestUserRecordSchema(TstLambdas): def test_transform_api_to_dynamo_permissions(self): @@ -67,3 +70,52 @@ def test_invalid_record(self): with self.assertRaises(ValidationError): UserRecordSchema().load(user_data) + + def test_record_loads_last_login_at(self): + from cc_common.data_model.schema.user.record import UserRecordSchema + + user_data = self._load_dynamo_user() + user_data['lastLoginAt'] = TEST_LAST_LOGIN_AT + + loaded_user = UserRecordSchema().load(user_data) + + self.assertEqual(datetime.fromisoformat(TEST_LAST_LOGIN_AT), loaded_user['lastLoginAt']) + + def test_record_loads_without_last_login_at(self): + """lastLoginAt is absent for users who have not signed in since login tracking was introduced.""" + from cc_common.data_model.schema.user.record import UserRecordSchema + + loaded_user = UserRecordSchema().load(self._load_dynamo_user()) + + self.assertNotIn('lastLoginAt', loaded_user) + + def test_record_round_trips_last_login_at(self): + from cc_common.data_model.schema.user.record import UserRecordSchema + + user_data = self._load_dynamo_user() + user_data['lastLoginAt'] = TEST_LAST_LOGIN_AT + + schema = UserRecordSchema() + dumped_user = schema.dump(schema.load(user_data)) + + self.assertEqual(TEST_LAST_LOGIN_AT, dumped_user['lastLoginAt']) + + def test_api_schema_accepts_last_login_at(self): + """UserAPISchema is a strict Schema, so every field the record schema loads must be declared there. + + Without this, adding a field to the record schema breaks the staff user endpoints for every user. + """ + from cc_common.data_model.schema.user.api import UserAPISchema + from cc_common.data_model.schema.user.record import UserRecordSchema + + user_data = self._load_dynamo_user() + user_data['lastLoginAt'] = TEST_LAST_LOGIN_AT + + loaded_user = UserAPISchema().load(UserRecordSchema().load(user_data)) + + self.assertEqual(datetime.fromisoformat(TEST_LAST_LOGIN_AT), loaded_user['lastLoginAt']) + + @staticmethod + def _load_dynamo_user() -> dict: + with open('../common/tests/resources/dynamo/user.json') as f: + return TypeDeserializer().deserialize({'M': json.load(f)}) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py new file mode 100644 index 0000000000..c84ce47a09 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py @@ -0,0 +1,128 @@ +from tests import TstLambdas +from tests.unit.staff_user_test_data import build_directory, build_staff_user + +ADMIN = 'admin' +WRITE = 'write' +READ_PRIVATE = 'readPrivate' + + +class TestResolveAdminRecipients(TstLambdas): + @staticmethod + def _resolve(user, directory): + from handlers.staff_user_inactivity import resolve_admin_recipients + + return resolve_admin_recipients(user=user, directory=directory) + + def test_state_user_notifies_that_states_admins_only(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, oh_admin, compact_admin])) + + self.assertEqual({oh_admin.email}, recipients) + + def test_user_in_two_states_notifies_both_admin_sets(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}, 'ne': {WRITE}}) + oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}) + ne_admin = build_staff_user(jurisdictions={'ne': {ADMIN}}) + + recipients = self._resolve(user, build_directory([user, oh_admin, ne_admin])) + + self.assertEqual({oh_admin.email, ne_admin.email}, recipients) + + def test_admin_in_two_of_the_users_states_is_only_listed_once(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}, 'ne': {WRITE}}) + both_states_admin = build_staff_user(jurisdictions={'oh': {ADMIN}, 'ne': {ADMIN}}) + + recipients = self._resolve(user, build_directory([user, both_states_admin])) + + self.assertEqual({both_states_admin.email}, recipients) + + def test_compact_level_user_notifies_compact_admins(self): + user = build_staff_user(compact_actions={READ_PRIVATE}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_user_who_is_the_states_only_admin_notifies_compact_admins(self): + user = build_staff_user(jurisdictions={'oh': {ADMIN}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_state_with_no_admins_notifies_compact_admins(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + oh_writer = build_staff_user(jurisdictions={'oh': {WRITE}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, oh_writer, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_user_is_never_in_their_own_recipient_set(self): + """A user who is both a compact admin and a state admin still must not be told about themselves.""" + user = build_staff_user(compact_actions={ADMIN}, jurisdictions={'oh': {ADMIN}}) + other_compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, other_compact_admin])) + + self.assertNotIn(user.email, recipients) + self.assertEqual({other_compact_admin.email}, recipients) + + def test_user_with_compact_and_state_permissions_notifies_both(self): + user = build_staff_user(compact_actions={READ_PRIVATE}, jurisdictions={'oh': {WRITE}}) + oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, oh_admin, compact_admin])) + + self.assertEqual({oh_admin.email, compact_admin.email}, recipients) + + def test_deactivated_state_admin_does_not_suppress_escalation(self): + """A deactivated admin cannot sign in to act, so the state counts as having no admins. + + Without this, a state whose only admin has been deactivated notifies a disabled account and + never escalates to the compact admins - so nobody who can act is told. + """ + from cc_common.data_model.schema.common import StaffUserStatus + + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + deactivated_oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}, status=StaffUserStatus.INACTIVE.value) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, deactivated_oh_admin, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_deactivated_compact_admin_is_not_notified(self): + from cc_common.data_model.schema.common import StaffUserStatus + + user = build_staff_user(compact_actions={READ_PRIVATE}) + deactivated_compact_admin = build_staff_user(compact_actions={ADMIN}, status=StaffUserStatus.INACTIVE.value) + + with self.assertLogs() as logs: + recipients = self._resolve(user, build_directory([user, deactivated_compact_admin])) + + self.assertEqual(set(), recipients) + self.assertTrue( + any(record.levelname == 'ERROR' for record in logs.records), + 'expected an ERROR log when there is no one to notify', + ) + + def test_no_admins_anywhere_returns_empty_set(self): + """A compact with nobody to notify is a configuration problem, but the user still gets their own email.""" + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + + with self.assertLogs() as logs: + recipients = self._resolve(user, build_directory([user])) + + self.assertEqual(set(), recipients) + self.assertTrue( + any(record.levelname == 'ERROR' for record in logs.records), + 'expected an ERROR log when there is no one to notify', + ) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py new file mode 100644 index 0000000000..dc3ed3b164 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py @@ -0,0 +1,96 @@ +from datetime import date + +from tests import TstLambdas +from tests.unit.staff_user_test_data import build_directory, build_staff_user + + +# The directory's own query and pagination are covered by the UserClient function tests. These tests +# feed it users directly, so they are only about how it classifies them. +class TestCompactStaffUserDirectory(TstLambdas): + _staff_user = staticmethod(build_staff_user) + _build_directory = staticmethod(build_directory) + + def test_users_last_seen_on_matches_only_that_date(self): + day_before = self._staff_user(last_login_at='2024-11-07T23:59:59+00:00') + target_day = self._staff_user(last_login_at='2024-11-08T00:00:00+00:00') + day_after = self._staff_user(last_login_at='2024-11-09T00:00:00+00:00') + + directory = self._build_directory([day_before, target_day, day_after]) + + self.assertEqual( + [target_day.userId], + [user.userId for user in directory.users_last_seen_on(date(2024, 11, 8))], + ) + + def test_users_last_seen_on_or_before_includes_earlier_dates(self): + day_before = self._staff_user(last_login_at='2024-11-07T23:59:59+00:00') + target_day = self._staff_user(last_login_at='2024-11-08T00:00:00+00:00') + day_after = self._staff_user(last_login_at='2024-11-09T00:00:00+00:00') + + directory = self._build_directory([day_before, target_day, day_after]) + + self.assertEqual( + {day_before.userId, target_day.userId}, + {user.userId for user in directory.users_last_seen_on_or_before(date(2024, 11, 8))}, + ) + + def test_matching_excludes_inactive_users(self): + """Already-deactivated users must not be swept up again.""" + from cc_common.data_model.schema.common import StaffUserStatus + + active = self._staff_user(last_login_at='2024-11-08T12:00:00+00:00') + inactive = self._staff_user(last_login_at='2024-11-08T12:00:00+00:00', status=StaffUserStatus.INACTIVE.value) + + directory = self._build_directory([active, inactive]) + + self.assertEqual([active.userId], [user.userId for user in directory.users_last_seen_on(date(2024, 11, 8))]) + self.assertEqual( + [active.userId], + [user.userId for user in directory.users_last_seen_on_or_before(date(2024, 11, 8))], + ) + + def test_matching_excludes_users_who_have_never_signed_in(self): + never_signed_in = self._staff_user(last_login_at=None) + + directory = self._build_directory([never_signed_in]) + + self.assertEqual([], directory.users_last_seen_on(date(2024, 11, 8))) + # Even a wide sweep must not pick up a user with no lastLoginAt at all + self.assertEqual([], directory.users_last_seen_on_or_before(date(2099, 1, 1))) + + def test_jurisdiction_admins(self): + from cc_common.data_model.schema.common import CCPermissionsAction + + oh_admin = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.ADMIN.value}}) + oh_writer = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.WRITE.value}}) + ne_admin = self._staff_user(jurisdictions={'ne': {CCPermissionsAction.ADMIN.value}}) + + directory = self._build_directory([oh_admin, oh_writer, ne_admin]) + + self.assertEqual([oh_admin.userId], [user.userId for user in directory.jurisdiction_admins('oh')]) + self.assertEqual([ne_admin.userId], [user.userId for user in directory.jurisdiction_admins('ne')]) + # A jurisdiction with no admins is empty, not a KeyError + self.assertEqual([], directory.jurisdiction_admins('ky')) + + def test_compact_admins(self): + from cc_common.data_model.schema.common import CCPermissionsAction + + compact_admin = self._staff_user(compact_actions={CCPermissionsAction.ADMIN.value}) + compact_reader = self._staff_user(compact_actions={CCPermissionsAction.READ_PRIVATE.value}) + jurisdiction_admin = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.ADMIN.value}}) + + directory = self._build_directory([compact_admin, compact_reader, jurisdiction_admin]) + + self.assertEqual([compact_admin.userId], [user.userId for user in directory.compact_admins]) + + def test_admin_lookups_do_not_require_matching(self): + """The directory is usable purely as an admin lookup, without matching users first.""" + from cc_common.data_model.schema.common import CCPermissionsAction + + compact_admin = self._staff_user(compact_actions={CCPermissionsAction.ADMIN.value}) + oh_admin = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.ADMIN.value}}) + + directory = self._build_directory([compact_admin, oh_admin]) + + self.assertEqual([compact_admin.userId], [user.userId for user in directory.compact_admins]) + self.assertEqual([oh_admin.userId], [user.userId for user in directory.jurisdiction_admins('oh')]) diff --git a/backend/social-work-app/pipeline/backend_stage.py b/backend/social-work-app/pipeline/backend_stage.py index 2819220250..65bc0368e0 100644 --- a/backend/social-work-app/pipeline/backend_stage.py +++ b/backend/social-work-app/pipeline/backend_stage.py @@ -15,6 +15,7 @@ from stacks.reporting_stack import ReportingStack from stacks.search_api_stack import SearchApiStack from stacks.search_persistent_stack import SearchPersistentStack +from stacks.staff_user_inactivity_stack import StaffUserInactivityStack from stacks.state_api_stack import StateApiStack from stacks.state_auth import StateAuthStack from stacks.vpc_stack import VpcStack @@ -173,6 +174,19 @@ def __init__( persistent_stack=self.persistent_stack, ) + # This job emails staff users before deactivating them, so it must not run in an + # environment that cannot send email because it does not have a hosted zone. + self.staff_user_inactivity_stack = StaffUserInactivityStack( + self, + 'StaffUserInactivityStack', + env=environment, + environment_context=environment_context, + environment_name=environment_name, + standard_tags=standard_tags, + persistent_stack=self.persistent_stack, + event_state_stack=self.event_state_stack, + ) + # Disaster recovery workflows for DynamoDB tables self.disaster_recovery_stack = DisasterRecoveryStack( self, diff --git a/backend/social-work-app/stacks/api_lambda_stack/staff_users.py b/backend/social-work-app/stacks/api_lambda_stack/staff_users.py index 609390c7f4..8cb9b225f1 100644 --- a/backend/social-work-app/stacks/api_lambda_stack/staff_users.py +++ b/backend/social-work-app/stacks/api_lambda_stack/staff_users.py @@ -447,6 +447,9 @@ def _reinvite_user_handler( 'cognito-idp:AdminResetUserPassword', 'cognito-idp:AdminSetUserPassword', 'cognito-idp:AdminCreateUser', + # A user deactivated for inactivity is disabled in Cognito, so re-inviting them has to + # re-enable them or the invitation is useless + 'cognito-idp:AdminEnableUser', ) NagSuppressions.add_resource_suppressions_by_path( diff --git a/backend/social-work-app/stacks/persistent_stack/staff_users.py b/backend/social-work-app/stacks/persistent_stack/staff_users.py index ff3079185a..7acdc97598 100644 --- a/backend/social-work-app/stacks/persistent_stack/staff_users.py +++ b/backend/social-work-app/stacks/persistent_stack/staff_users.py @@ -3,6 +3,8 @@ import json from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction from aws_cdk.aws_cognito import ( ClientAttributes, LambdaVersion, @@ -13,6 +15,7 @@ UserPoolOperation, ) from aws_cdk.aws_kms import IKey +from aws_cdk.aws_logs import FilterPattern, MetricFilter from cdk_nag import NagSuppressions from common_constructs.nodejs_function import NodejsFunction from common_constructs.python_function import PythonFunction @@ -124,7 +127,7 @@ def _add_scope_customization(self, stack: ps.PersistentStack): compacts = self.node.get_context('compacts') jurisdictions = self.node.get_context('jurisdictions') - scope_customization_handler = PythonFunction( + self.scope_customization_handler = PythonFunction( self, 'ScopeCustomizationHandler', description='Auth scope customization handler', @@ -140,10 +143,37 @@ def _add_scope_customization(self, stack: ps.PersistentStack): **stack.common_env_vars, }, ) - self.user_table.grant_read_write_data(scope_customization_handler) + self.user_table.grant_read_write_data(self.scope_customization_handler) + + # This handler swallows its own errors so that a failure cannot block authentication, which means + # a broken scope calculation or an unrecorded login would otherwise be invisible. The ERROR logs it + # writes instead are the only signal, so we alarm on them. + scope_customization_error_metric = MetricFilter( + self, + 'ScopeCustomizationHandlerErrorLogMetric', + log_group=self.scope_customization_handler.log_group, + metric_namespace='CompactConnect/StaffUsers', + metric_name='ScopeCustomizationHandlerErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + + Alarm( + self, + 'ScopeCustomizationHandlerErrorLogAlarm', + metric=scope_customization_error_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'The Scope Customization Lambda logged an ERROR level message. Investigate the logs ' + f'for the {self.scope_customization_handler.function_name} lambda to determine the cause.', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(stack.alarm_topic)) NagSuppressions.add_resource_suppressions( - scope_customization_handler.role, + self.scope_customization_handler.role, apply_to_children=True, suppressions=[ { @@ -157,7 +187,7 @@ def _add_scope_customization(self, stack: ps.PersistentStack): ) self.add_trigger( UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG, - scope_customization_handler, + self.scope_customization_handler, lambda_version=LambdaVersion.V2_0, ) diff --git a/backend/social-work-app/stacks/staff_user_inactivity_stack.py b/backend/social-work-app/stacks/staff_user_inactivity_stack.py new file mode 100644 index 0000000000..ad1c5daf13 --- /dev/null +++ b/backend/social-work-app/stacks/staff_user_inactivity_stack.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import json +import os + +from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import Rule, RuleTargetInput, Schedule +from aws_cdk.aws_events_targets import LambdaFunction +from aws_cdk.aws_logs import FilterPattern, MetricFilter, QueryDefinition, QueryString, RetentionDays +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import event_state_stack as ess +from stacks import persistent_stack as ps + +# How long a staff user can go without signing in before their account is deactivated +STAFF_USER_INACTIVITY_PERIOD_DAYS = 60 + +# Days before the stated deactivation date that each rule fires. 0 is the run that deactivates - it +# sends no notice of its own, since the 1-day run already sent the last actionable one. +REMINDER_CONFIGS = [ + {'days_before': 10, 'suffix': '10Day'}, + {'days_before': 3, 'suffix': '3Day'}, + {'days_before': 1, 'suffix': '1Day'}, + {'days_before': 0, 'suffix': 'DayOf'}, +] + +# Jurisdictions span Guam/N. Mariana (UTC+10) through Hawaii (UTC-10), so only 10:00-13:00 UTC is the +# same calendar date everywhere. Running outside that window would deactivate accounts a day before the +# date the notification emails state. See docs/staff-user-inactivity-deactivation-notifications-design.md +RUN_HOUR_UTC = '12' + + +class StaffUserInactivityStack(AppStack): + """ + Stack for staff user inactivity notifications and deactivation. + + - Lambda that notifies a staff user and their administrators ahead of deactivation (10-day, 3-day, + and 1-day runs), and deactivates on the day-of run + - EventBridge rules per compact and reminder type (10-day, 3-day, 1-day, day-of) that run daily + - CloudWatch alarms for errors and execution duration + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + event_state_stack: ess.EventStateStack, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + + self.staff_user_inactivity_handler = PythonFunction( + self, + 'StaffUserInactivityHandler', + description='Processes staff user inactivity notifications and deactivations', + lambda_dir='staff-users', + index=os.path.join('handlers', 'staff_user_inactivity.py'), + handler='process_staff_user_inactivity', + timeout=Duration.minutes(15), + memory_size=1024, + log_retention=RetentionDays.ONE_MONTH, + environment={ + 'USER_POOL_ID': persistent_stack.staff_users.user_pool_id, + 'USERS_TABLE_NAME': persistent_stack.staff_users.user_table.table_name, + 'FAM_GIV_INDEX_NAME': persistent_stack.staff_users.user_table.family_given_index_name, + 'EVENT_STATE_TABLE_NAME': event_state_stack.event_state_table.table_name, + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': ( + persistent_stack.email_notification_service_lambda.function_name + ), + 'STAFF_USER_INACTIVITY_PERIOD_DAYS': str(STAFF_USER_INACTIVITY_PERIOD_DAYS), + **self.common_env_vars, + }, + alarm_topic=persistent_stack.alarm_topic, + ) + + # Write access is needed to mark deactivated users inactive + persistent_stack.staff_users.user_table.grant_read_write_data(self.staff_user_inactivity_handler) + event_state_stack.event_state_table.grant_read_write_data(self.staff_user_inactivity_handler) + persistent_stack.email_notification_service_lambda.grant_invoke(self.staff_user_inactivity_handler) + persistent_stack.staff_users.grant(self.staff_user_inactivity_handler, 'cognito-idp:AdminDisableUser') + + NagSuppressions.add_resource_suppressions_by_path( + self, + f'{self.staff_user_inactivity_handler.role.node.path}/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'This policy contains wild-carded actions and resources but they are scoped to the ' + 'specific actions, KMS key, Table, and Lambda that this lambda specifically needs access to.', + }, + ], + ) + + # All four rules fire in the same minute. + for compact in json.loads(self.common_env_vars['COMPACTS']): + for reminder_config in REMINDER_CONFIGS: + days_before = reminder_config['days_before'] + description = ( + f'Daily rule to deactivate inactive staff users in {compact}' + if days_before == 0 + else f'Daily rule to notify staff users in {compact} {days_before} days before ' + 'inactivity deactivation' + ) + Rule( + self, + f'StaffUserInactivity{reminder_config["suffix"]}Rule{compact.upper()}', + description=description, + schedule=Schedule.cron(week_day='*', hour=RUN_HOUR_UTC, minute='0', month='*', year='*'), + targets=[ + LambdaFunction( + handler=self.staff_user_inactivity_handler, + event=RuleTargetInput.from_object( + { + 'compact': compact, + 'daysBeforeDeactivation': days_before, + } + ), + ) + ], + ) + + Alarm( + self, + 'StaffUserInactivityErrorAlarm', + metric=self.staff_user_inactivity_handler.metric_errors(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{self.staff_user_inactivity_handler.node.path} failed to process staff user inactivity', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + Alarm( + self, + 'StaffUserInactivityDurationAlarm', + metric=self.staff_user_inactivity_handler.metric_duration(statistic=Stats.MAXIMUM, period=Duration.days(1)), + evaluation_periods=1, + threshold=600_000, # 10 minutes in milliseconds + actions_enabled=True, + alarm_description=f'{self.staff_user_inactivity_handler.node.path} Lambda Duration exceeded 10 minutes', + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + # Several failure paths in the handler (a failed send, a failed deactivation, no admins found to + # notify) are deliberately swallowed per-user so one bad record can't fail the whole batch, and + # so never surface on the Lambda Errors metric above. The ERROR logs they write are the only + # signal, so we alarm on those directly. + error_log_metric = MetricFilter( + self, + 'StaffUserInactivityErrorLogMetric', + log_group=self.staff_user_inactivity_handler.log_group, + metric_namespace='CompactConnect/StaffUsers', + metric_name='StaffUserInactivityHandlerErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + + Alarm( + self, + 'StaffUserInactivityErrorLogAlarm', + metric=error_log_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'The Staff User Inactivity Lambda logged an ERROR level message. Investigate the ' + f'logs for the {self.staff_user_inactivity_handler.function_name} lambda to determine the cause.', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + QueryDefinition( + self, + 'StaffUserInactivityQuery', + query_definition_name=f'{self.node.id}/StaffUserInactivityHandler', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'message', 'compact', 'user_id', 'event_type', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=[self.staff_user_inactivity_handler.log_group], + ) diff --git a/backend/social-work-app/tests/app/base.py b/backend/social-work-app/tests/app/base.py index 5cf8248507..9a3f7070b3 100644 --- a/backend/social-work-app/tests/app/base.py +++ b/backend/social-work-app/tests/app/base.py @@ -525,6 +525,7 @@ def _check_no_backend_stage_annotations(self, stage: BackendStage): if stage.persistent_stack.hosted_zone: self._check_no_stack_annotations(stage.notification_stack) self._check_no_stack_annotations(stage.reporting_stack) + self._check_no_stack_annotations(stage.staff_user_inactivity_stack) # No backup stack here, because nexted stack annotations are checked in the parent stack def _count_stack_resources(self, stack: Stack) -> int: diff --git a/backend/social-work-app/tests/app/test_api/test_staff_users_api.py b/backend/social-work-app/tests/app/test_api/test_staff_users_api.py index f0511a311d..fd141b1b3f 100644 --- a/backend/social-work-app/tests/app/test_api/test_staff_users_api.py +++ b/backend/social-work-app/tests/app/test_api/test_staff_users_api.py @@ -1,6 +1,7 @@ from aws_cdk.assertions import Capture, Match, Template from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_iam import CfnPolicy from aws_cdk.aws_lambda import CfnFunction from tests.app.test_api import TestApi @@ -313,3 +314,27 @@ def test_synth_generates_reinvite_user_endpoint_resource(self): ], }, ) + + def test_synth_grants_reinvite_user_handler_permission_to_re_enable_cognito_users(self): + """A user deactivated for inactivity is disabled in Cognito, so the reinvite handler has to be able + to re-enable them. Without this grant the invitation is sent but the user still cannot sign in. + """ + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + role_logical_id = api_lambda_stack.get_logical_id( + api_lambda_stack.staff_users_lambdas.reinvite_user_handler.role.node.default_child + ) + role_policies = api_lambda_stack_template.find_resources( + type=CfnPolicy.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'Roles': [{'Ref': role_logical_id}]}}, + ) + self.assertTrue(role_policies, 'No IAM policy found for the reinvite user handler role') + + granted_actions = set() + for policy in role_policies.values(): + for statement in policy['Properties']['PolicyDocument']['Statement']: + actions = statement['Action'] + granted_actions.update(actions if isinstance(actions, list) else [actions]) + + self.assertIn('cognito-idp:AdminEnableUser', granted_actions) diff --git a/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py new file mode 100644 index 0000000000..20e1410987 --- /dev/null +++ b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py @@ -0,0 +1,169 @@ +import json +from unittest import TestCase + +from aws_cdk.assertions import Template +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_iam import CfnPolicy +from aws_cdk.aws_lambda import CfnFunction +from aws_cdk.aws_logs import CfnMetricFilter + +from tests.app.base import TstAppABC + +# Only 10:00-13:00 UTC is the same calendar date in every jurisdiction the compact covers, from +# Guam (UTC+10) through Hawaii (UTC-10). Running outside that window would deactivate accounts a day +# before the date the notification emails state. +EXPECTED_SCHEDULE = 'cron(0 12 ? * * *)' +EXPECTED_RULE_SUFFIXES = ('10Day', '3Day', '1Day', 'DayOf') +EXPECTED_DAYS_BEFORE = {'10Day': 10, '3Day': 3, '1Day': 1, 'DayOf': 0} + + +class TestStaffUserInactivityStack(TstAppABC, TestCase): + """ + Test cases for the StaffUserInactivityStack, which notifies staff users ahead of inactivity + deactivation and deactivates them on the day-of run. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + @property + def _stack(self): + return self.app.sandbox_backend_stage.staff_user_inactivity_stack + + def test_handler_created_with_expected_configuration(self): + template = Template.from_stack(self._stack) + + handler = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id(self._stack.staff_user_inactivity_handler.node.default_child), + template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual('handlers.staff_user_inactivity.process_staff_user_inactivity', handler['Handler']) + self.assertEqual(900, handler['Timeout']) + self.assertEqual('60', handler['Environment']['Variables']['STAFF_USER_INACTIVITY_PERIOD_DAYS']) + + def test_eventbridge_rules_created_for_each_compact_and_reminder_type(self): + template = Template.from_stack(self._stack) + rules = template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME) + compacts = self.get_context()['compacts'] + + self.assertEqual( + len(compacts) * len(EXPECTED_RULE_SUFFIXES), + len(rules), + 'Expected one rule per compact per reminder type', + ) + + handler_logical_id = self._stack.get_logical_id(self._stack.staff_user_inactivity_handler.node.default_child) + for compact in compacts: + for suffix in EXPECTED_RULE_SUFFIXES: + rule_name = f'StaffUserInactivity{suffix}Rule{compact.upper()}' + with self.subTest(rule=rule_name): + rule = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id(self._stack.node.find_child(rule_name).node.default_child), + rules, + ) + + self.assertEqual(EXPECTED_SCHEDULE, rule['ScheduleExpression']) + self.assertEqual('ENABLED', rule['State']) + + target = rule['Targets'][0] + self.assertEqual(handler_logical_id, target['Arn']['Fn::GetAtt'][0]) + self.assertEqual( + {'compact': compact, 'daysBeforeDeactivation': EXPECTED_DAYS_BEFORE[suffix]}, + json.loads(target['Input']), + ) + + def test_handler_granted_permission_to_disable_cognito_users(self): + """The day-of run disables users in Cognito, which is what actually revokes their access.""" + template = Template.from_stack(self._stack) + + role_logical_id = self._stack.get_logical_id(self._stack.staff_user_inactivity_handler.role.node.default_child) + role_policies = template.find_resources( + type=CfnPolicy.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'Roles': [{'Ref': role_logical_id}]}}, + ) + self.assertTrue(role_policies, 'No IAM policy found for the staff user inactivity handler role') + + granted_actions = set() + for policy in role_policies.values(): + for statement in policy['Properties']['PolicyDocument']['Statement']: + actions = statement['Action'] + granted_actions.update(actions if isinstance(actions, list) else [actions]) + + self.assertIn('cognito-idp:AdminDisableUser', granted_actions) + # Deactivation marks every one of the user's records inactive, so read alone is not enough + self.assertIn('dynamodb:UpdateItem', granted_actions) + + def test_alarms_configured(self): + template = Template.from_stack(self._stack) + alarms = template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) + + error_alarm = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id(self._stack.node.find_child('StaffUserInactivityErrorAlarm').node.default_child), + alarms, + ) + self.assertEqual(1, error_alarm['Threshold']) + self.assertEqual('GreaterThanOrEqualToThreshold', error_alarm['ComparisonOperator']) + self.assertEqual('Errors', error_alarm['MetricName']) + + duration_alarm = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id( + self._stack.node.find_child('StaffUserInactivityDurationAlarm').node.default_child + ), + alarms, + ) + self.assertEqual(600_000, duration_alarm['Threshold']) + self.assertEqual('GreaterThanThreshold', duration_alarm['ComparisonOperator']) + self.assertEqual('Duration', duration_alarm['MetricName']) + + def test_error_log_metric_filter_created(self): + """Several failure paths in this handler (a failed send, a failed deactivation, no admins to + notify) are deliberately swallowed per-user so one bad record can't fail the whole batch - so + they never surface on the Lambda Errors metric. The ERROR logs they write are the only signal.""" + template = Template.from_stack(self._stack) + + metric_filter = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id( + self._stack.node.find_child('StaffUserInactivityErrorLogMetric').node.default_child + ), + template.find_resources(CfnMetricFilter.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual('{ $.level = "ERROR" }', metric_filter['FilterPattern']) + transformation = metric_filter['MetricTransformations'][0] + self.assertEqual('CompactConnect/StaffUsers', transformation['MetricNamespace']) + self.assertEqual('StaffUserInactivityHandlerErrors', transformation['MetricName']) + self.assertEqual('1', transformation['MetricValue']) + self.assertEqual(0, transformation['DefaultValue']) + + def test_error_log_alarm_created_and_notifies_the_alarm_topic(self): + template = Template.from_stack(self._stack) + + alarm = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id( + self._stack.node.find_child('StaffUserInactivityErrorLogAlarm').node.default_child + ), + template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual('StaffUserInactivityHandlerErrors', alarm['MetricName']) + self.assertEqual('CompactConnect/StaffUsers', alarm['Namespace']) + self.assertEqual('Sum', alarm['Statistic']) + self.assertEqual(1, alarm['Threshold']) + self.assertEqual(1, alarm['EvaluationPeriods']) + self.assertEqual('GreaterThanOrEqualToThreshold', alarm['ComparisonOperator']) + self.assertEqual('notBreaching', alarm['TreatMissingData']) + + # The alarm topic lives in a different stack, so this resolves to a cross-stack Fn::ImportValue + # rather than a same-stack Ref - just confirm exactly one action was wired up, not its exact name. + self.assertEqual(1, len(alarm['AlarmActions'])) + self.assertIn('Fn::ImportValue', alarm['AlarmActions'][0]) diff --git a/backend/social-work-app/tests/smoke/README.md b/backend/social-work-app/tests/smoke/README.md index 0edc5a992b..c011341ffa 100644 --- a/backend/social-work-app/tests/smoke/README.md +++ b/backend/social-work-app/tests/smoke/README.md @@ -79,6 +79,8 @@ Some smoke tests create their own practitioner data via the state API (for examp - `CC_TEST_ROLLBACK_STEP_FUNCTION_ARN`: Step function ARN for rollback tests - `CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME`: DynamoDB table name for rate limiting - `CC_TEST_SSN_DYNAMO_TABLE_NAME`: DynamoDB table name for SSN data + - `CC_TEST_STAFF_USER_INACTIVITY_LAMBDA_NAME`: Function name of the staff user inactivity handler, used by + `staff_user_inactivity_smoke_tests.py` (found in the `StaffUserInactivityStack`) 3. **Important:** Never commit `smoke_tests_env.json` to version control. It contains sensitive credentials and should be in `.gitignore`. diff --git a/backend/social-work-app/tests/smoke/config.py b/backend/social-work-app/tests/smoke/config.py index a86ee8bde1..6be0a98bd4 100644 --- a/backend/social-work-app/tests/smoke/config.py +++ b/backend/social-work-app/tests/smoke/config.py @@ -76,6 +76,10 @@ def test_provider_id(self): def smoke_test_notification_email(self): return os.environ['CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL'] + @property + def staff_user_inactivity_lambda_name(self): + return os.environ['CC_TEST_STAFF_USER_INACTIVITY_LAMBDA_NAME'] + @cached_property def cognito_client(self): return boto3.client('cognito-idp') diff --git a/backend/social-work-app/tests/smoke/smoke_common.py b/backend/social-work-app/tests/smoke/smoke_common.py index 808e857abc..dd7bf8369b 100644 --- a/backend/social-work-app/tests/smoke/smoke_common.py +++ b/backend/social-work-app/tests/smoke/smoke_common.py @@ -43,9 +43,13 @@ def __init__(self, message): _TEMP_STAFF_PASSWORD = 'TempPass123!' # noqa: S105 temporary password for creating test staff users -def _create_staff_user_in_cognito(*, email: str) -> str: +def _create_staff_user_in_cognito(*, email: str, suppress_welcome_message: bool = False) -> str: """ Creates a staff user in Cognito and returns the user's sub. + + :param email: The email address for the new user + :param suppress_welcome_message: If True, Cognito will not send its default welcome/invite email. + Defaults to False to preserve the existing behavior for smoke tests that rely on that email. """ def get_sub_from_attributes(user_attributes: list): @@ -55,12 +59,15 @@ def get_sub_from_attributes(user_attributes: list): raise ValueError('Failed to find user sub!') try: - user_data = config.cognito_client.admin_create_user( - UserPoolId=config.cognito_staff_user_pool_id, - Username=email, - UserAttributes=[{'Name': 'email', 'Value': email}], - TemporaryPassword=_TEMP_STAFF_PASSWORD, - ) + create_user_kwargs = { + 'UserPoolId': config.cognito_staff_user_pool_id, + 'Username': email, + 'UserAttributes': [{'Name': 'email', 'Value': email}], + 'TemporaryPassword': _TEMP_STAFF_PASSWORD, + } + if suppress_welcome_message: + create_user_kwargs['MessageAction'] = 'SUPPRESS' + user_data = config.cognito_client.admin_create_user(**create_user_kwargs) logger.info(f"Created staff user, '{email}'. Setting password.") # set this to simplify login flow for user config.cognito_client.admin_set_user_password( @@ -104,18 +111,22 @@ def delete_test_staff_user(email: str, user_sub: str, compact: str): raise e -def create_test_staff_user(*, email: str, compact: str, jurisdiction: str, permissions: dict): +def create_test_staff_user( + *, email: str, compact: str, jurisdiction: str, permissions: dict, suppress_welcome_message: bool = False +): """Creates a test staff user in Cognito, stores their data in DynamoDB, and returns their user sub id. :param email: The email address of the staff user to create :param compact: The compact identifier :param jurisdiction: The jurisdiction identifier :param permissions: The permissions dictionary for the user + :param suppress_welcome_message: If True, Cognito will not send its default welcome/invite email. + Defaults to False to preserve the existing behavior for smoke tests that rely on that email. :return: The staff user's sub ID """ logger.info(f"Creating staff user, '{email}', in {compact}/{jurisdiction}") user_attributes = {'email': email, 'familyName': 'Dokes', 'givenName': 'Joe'} - sub = _create_staff_user_in_cognito(email=email) + sub = _create_staff_user_in_cognito(email=email, suppress_welcome_message=suppress_welcome_message) schema = UserRecordSchema() config.staff_users_dynamodb_table.put_item( Item=schema.dump( diff --git a/backend/social-work-app/tests/smoke/smoke_tests_env_example.json b/backend/social-work-app/tests/smoke/smoke_tests_env_example.json index b2416e8688..315947d3b2 100644 --- a/backend/social-work-app/tests/smoke/smoke_tests_env_example.json +++ b/backend/social-work-app/tests/smoke/smoke_tests_env_example.json @@ -14,5 +14,6 @@ "CC_TEST_PROVIDER_ID": "exampleProviderId", "ENVIRONMENT_NAME": "sandboxEnvironmentNamePlaceholder", "CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL": "smoke-test-notifications@example.com", - "CC_TEST_ROLLBACK_STEP_FUNCTION_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:Sandbox-DisasterRecoveryStack-LicenseUploadRollbackStateMachine" + "CC_TEST_ROLLBACK_STEP_FUNCTION_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:Sandbox-DisasterRecoveryStack-LicenseUploadRollbackStateMachine", + "CC_TEST_STAFF_USER_INACTIVITY_LAMBDA_NAME": "Sandbox-StaffUserInactivityStack-StaffUserInactivityHandler12345" } diff --git a/backend/social-work-app/tests/smoke/staff_user_inactivity_smoke_tests.py b/backend/social-work-app/tests/smoke/staff_user_inactivity_smoke_tests.py new file mode 100644 index 0000000000..cd382a70ef --- /dev/null +++ b/backend/social-work-app/tests/smoke/staff_user_inactivity_smoke_tests.py @@ -0,0 +1,273 @@ +# ruff: noqa: T201 we use print statements for smoke testing +#!/usr/bin/env python3 +""" +Smoke tests for staff user inactivity notifications and deactivation. + +Walks one staff user through the whole inactivity lifecycle by moving their lastLoginAt backwards and +invoking the scheduled handler for each reminder type: + + 51 days dormant -> 10-day notice + 58 days dormant -> 3-day notice + 60 days dormant -> 1-day notice, on the user's last usable day, while they can still act on it + 61 days dormant -> the account is deactivated, with no further notice + +This is primarily a developer-verified test: CompactConnect reporting a send as successful only means SES +accepted it, not that the message arrived or reads correctly, so each notice-producing phase pauses and +asks the developer to check two inboxes before continuing: + + - the staff user's own address, CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL + - a state admin address built by adding a "+state+admin" suffix to that same address, so both land + in the same real mailbox as distinguishable messages + +That check can still fail through no fault of this test, though: all of a jurisdiction's admins are +notified in a single combined send (one email, several recipients), and SES can reject the whole send if +any one recipient is not a verified identity. If +STAFF_USER_INACTIVITY_SMOKE_JURISDICTION already has another staff admin in that environment with a bad +address, the email will fail to send. Pick a jurisdiction with no other staff users in the environment +you are running against, and change the constant below if needed. + +This is a property of the SES sandbox, which testing accounts are in (see the SES notes in README.md). +Production accounts have sandbox access removed, so there an undeliverable address bounces on its own +rather than failing the send for everyone else on it. + +WARNING - Only run this test against testing environments. Invoking the handler runs it against every +staff user in the compact, not just the test user. The day-of invocation is a sweep, so any other staff +user in that environment who has been dormant for more than the inactivity period will be notified and +deactivated too. + +Because of that, the metric assertions below are deliberately lower bounds rather than exact counts - +other users may legitimately appear in the same run. The test user's own outcome is verified directly +against Cognito and DynamoDB. + +Both staff users created here - and their DynamoDB records - are always cleaned up, pass or fail. +""" + +import json +from datetime import UTC, datetime, timedelta + +from smoke_common import ( + SmokeTestFailureException, + config, + create_test_staff_user, + delete_test_staff_user, + get_lambda_client, + load_smoke_test_env, + logger, +) + +STAFF_USER_INACTIVITY_SMOKE_COMPACT = 'socw' +# Must have no other staff users in the environment this runs against - see the module docstring for why +# a pre-existing admin with a bad address here would fail the adminEmailsSent check. +STAFF_USER_INACTIVITY_SMOKE_JURISDICTION = 'wa' + +# The handler deactivates the day after the inactivity period elapses, so the user keeps all of day 60. +INACTIVITY_PERIOD_DAYS = 60 +DAYS_TO_DEACTIVATION = INACTIVITY_PERIOD_DAYS + 1 + + +def _state_admin_email(staff_user_email: str) -> str: + """Build a distinguishable admin address that still lands in the same real mailbox. + + Most mail providers, including the ones these smoke test env files already use, deliver a + "local+anything@domain" address to the same inbox as "local@domain". + """ + local, domain = staff_user_email.split('@', 1) + return f'{local}+state+admin@{domain}' + + +def _set_last_login_days_ago(user_sub: str, days_ago: int) -> None: + """Move the user's lastLoginAt back far enough to land them in the cohort we want to exercise.""" + last_login_at = (datetime.now(tz=UTC) - timedelta(days=days_ago)).isoformat() + logger.info(f'Setting lastLoginAt to {days_ago} days ago ({last_login_at})') + config.staff_users_dynamodb_table.update_item( + Key={'pk': f'USER#{user_sub}', 'sk': f'COMPACT#{STAFF_USER_INACTIVITY_SMOKE_COMPACT}'}, + UpdateExpression='SET lastLoginAt = :lastLoginAt', + ExpressionAttributeValues={':lastLoginAt': last_login_at}, + ) + + +def _invoke_inactivity_handler(days_before_deactivation: int) -> dict: + """Invoke the scheduled handler exactly as its EventBridge rule would, and return its metrics.""" + logger.info(f'Invoking staff user inactivity handler with daysBeforeDeactivation={days_before_deactivation}') + response = get_lambda_client().invoke( + FunctionName=config.staff_user_inactivity_lambda_name, + InvocationType='RequestResponse', + Payload=json.dumps( + { + 'compact': STAFF_USER_INACTIVITY_SMOKE_COMPACT, + 'daysBeforeDeactivation': days_before_deactivation, + } + ), + ) + + payload = json.loads(response['Payload'].read()) + if response.get('FunctionError'): + raise SmokeTestFailureException(f'Inactivity handler failed: {payload}') + + logger.info(f'Handler returned metrics: {payload["metrics"]}') + return payload['metrics'] + + +def _prompt_developer_to_verify_email(description: str, *, user_email: str, admin_email: str) -> None: + """Ask the developer to confirm the notice actually arrived and reads correctly. + + A successful send just means SES accepted the message - it does not prove delivery or content, so a + human has to look. A "n" answer fails the test via SmokeTestFailureException, same as any other + assertion here, so the run still reaches main()'s finally block and cleans up both test users. + """ + print('\n' + '=' * 78) + print(f'MANUAL VERIFICATION NEEDED: {description}') + print(f' Staff user inbox : {user_email}') + print(f' State admin inbox: {admin_email}') + print(' (Both addresses route to the same mailbox via the "+" suffix.)') + print('=' * 78) + answer = input('Did both emails arrive and read as expected? [y/n]: ').strip().lower() + if answer != 'y': + raise SmokeTestFailureException(f'Developer did not confirm {description} - check the inboxes above') + + +def _verify_notified(metrics: dict, *, run_description: str, user_email: str, admin_email: str) -> None: + """Check that CompactConnect attempted to notify the test user and at least one state admin, then + have the developer confirm the emails actually arrived. + + Lower bounds, not exact counts: other staff users in the environment may be in the same cohort. The + test user's lastLoginAt was just moved to a date they have never been notified for, so the tracker + cannot suppress their notice - at least one send must be reported for each audience. + """ + if metrics['matchedUsers'] < 1: + raise SmokeTestFailureException(f'Expected the test user in the {run_description} cohort, got: {metrics}') + if metrics['userEmailsSent'] < 1: + raise SmokeTestFailureException(f'Expected a notice addressed to the test user, got: {metrics}') + if metrics['adminEmailsSent'] < 1: + raise SmokeTestFailureException(f'Expected a notice addressed to a state admin, got: {metrics}') + + _prompt_developer_to_verify_email(f'the {run_description} notice', user_email=user_email, admin_email=admin_email) + + +def _get_user_status(user_sub: str) -> str: + record = config.staff_users_dynamodb_table.get_item( + Key={'pk': f'USER#{user_sub}', 'sk': f'COMPACT#{STAFF_USER_INACTIVITY_SMOKE_COMPACT}'} + ).get('Item') + if record is None: + raise SmokeTestFailureException('Test staff user record is missing from DynamoDB') + return record['status'] + + +def _is_cognito_user_enabled(email: str) -> bool: + user_data = config.cognito_client.admin_get_user( + UserPoolId=config.cognito_staff_user_pool_id, + Username=email, + ) + return user_data['Enabled'] + + +def test_ten_day_notice(user_sub: str, email: str, admin_email: str): + """A user 51 days dormant is 10 days from deactivation.""" + _set_last_login_days_ago(user_sub, DAYS_TO_DEACTIVATION - 10) + + metrics = _invoke_inactivity_handler(10) + + _verify_notified(metrics, run_description='10-day', user_email=email, admin_email=admin_email) + if metrics['deactivated'] != 0: + raise SmokeTestFailureException(f'The 10-day run must not deactivate anyone, got: {metrics}') + + logger.info('10-day notice sent as expected') + + +def test_three_day_notice(user_sub: str, email: str, admin_email: str): + """A user 58 days dormant is 3 days from deactivation.""" + _set_last_login_days_ago(user_sub, DAYS_TO_DEACTIVATION - 3) + + metrics = _invoke_inactivity_handler(3) + + _verify_notified(metrics, run_description='3-day', user_email=email, admin_email=admin_email) + if metrics['deactivated'] != 0: + raise SmokeTestFailureException(f'The 3-day run must not deactivate anyone, got: {metrics}') + + logger.info('3-day notice sent as expected') + + +def test_one_day_notice(user_sub: str, email: str, admin_email: str): + """The 1-day notice arrives on the user's last usable day, and does not deactivate them.""" + _set_last_login_days_ago(user_sub, INACTIVITY_PERIOD_DAYS) + + metrics = _invoke_inactivity_handler(1) + + _verify_notified(metrics, run_description='1-day', user_email=email, admin_email=admin_email) + if metrics['deactivated'] != 0: + raise SmokeTestFailureException(f'The 1-day run must not deactivate anyone, got: {metrics}') + if not _is_cognito_user_enabled(email): + raise SmokeTestFailureException( + f'The test user was disabled after only {INACTIVITY_PERIOD_DAYS} days - they should keep their final day' + ) + if _get_user_status(user_sub) != 'active': + raise SmokeTestFailureException(f'The test user was marked inactive after only {INACTIVITY_PERIOD_DAYS} days') + + logger.info(f'1-day notice sent at {INACTIVITY_PERIOD_DAYS} days dormant, without deactivating') + + +def test_day_of_deactivation_without_a_notice(user_sub: str, email: str): + """One day further on, the user is deactivated. The 1-day run already sent the last notice, so the + day-of run must not send another - nothing to manually verify here.""" + _set_last_login_days_ago(user_sub, DAYS_TO_DEACTIVATION) + + metrics = _invoke_inactivity_handler(0) + + if metrics['deactivated'] < 1: + raise SmokeTestFailureException(f'Expected the test user to be deactivated, got: {metrics}') + if metrics['userEmailsSent'] != 0 or metrics['adminEmailsSent'] != 0: + raise SmokeTestFailureException(f'The day-of run must not send a notice, got: {metrics}') + + if _is_cognito_user_enabled(email): + raise SmokeTestFailureException('The test user is still enabled in Cognito after deactivation') + if _get_user_status(user_sub) != 'inactive': + raise SmokeTestFailureException('The test user record was not marked inactive after deactivation') + + logger.info('Account was deactivated, with no further notice, in both Cognito and DynamoDB') + + +def main(): + load_smoke_test_env() + + email = config.smoke_test_notification_email + admin_email = _state_admin_email(email) + user_sub = None + admin_sub = None + + try: + # A state admin for the same jurisdiction, so resolve_admin_recipients has someone real to find + # instead of falling back to compact admins already in the shared environment + admin_sub = create_test_staff_user( + email=admin_email, + compact=STAFF_USER_INACTIVITY_SMOKE_COMPACT, + jurisdiction=STAFF_USER_INACTIVITY_SMOKE_JURISDICTION, + permissions={'jurisdictions': {STAFF_USER_INACTIVITY_SMOKE_JURISDICTION: {'admin'}}}, + suppress_welcome_message=True, + ) + user_sub = create_test_staff_user( + email=email, + compact=STAFF_USER_INACTIVITY_SMOKE_COMPACT, + jurisdiction=STAFF_USER_INACTIVITY_SMOKE_JURISDICTION, + permissions={'jurisdictions': {STAFF_USER_INACTIVITY_SMOKE_JURISDICTION: {'write'}}}, + suppress_welcome_message=True, + ) + + test_ten_day_notice(user_sub, email, admin_email) + test_three_day_notice(user_sub, email, admin_email) + test_one_day_notice(user_sub, email, admin_email) + test_day_of_deactivation_without_a_notice(user_sub, email) + + except Exception as e: + logger.error(f'Staff user inactivity smoke tests failed: {str(e)}') + raise + finally: + if user_sub: + delete_test_staff_user(email, user_sub, STAFF_USER_INACTIVITY_SMOKE_COMPACT) + if admin_sub: + delete_test_staff_user(admin_email, admin_sub, STAFF_USER_INACTIVITY_SMOKE_COMPACT) + + logger.info('All staff user inactivity smoke tests passed!') + + +if __name__ == '__main__': + main()