Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow disabling of users #205

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ In this section, we describe all user-related data to be stored. The security ru
|Property|Type|Values|Comments|
|-|-|-|-|
|type|string|e.g. "admin", "owners", "clinician", "patient"|The type of the user.|
|disabled|optional boolean|If set to `true`, the users looses permission to perform any action except reading its own data stored in `users/$userId$`.|
|dateOfEnrollment|Date|-|The date when the invitation code was used to create this user.|
|dateOfBirth|optional Date|-|The date when the user was born.|
|genderIdentity|optional string|"female","male","transgender","nonBinary","preferNotToState"|The gender identity chosen when a patient redeemed the invitation.|
Expand Down
39 changes: 26 additions & 13 deletions firestore.rules
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
rules_version = '2';
service cloud.firestore {
match /databases/{databaseId}/documents {
function valueExists(object, property) {
return (property in object) && object[property] != null;
}

function isEqualOptional(object0, property0, object1, property1) {
return valueExists(object0, property0)
? (valueExists(object1, property1) && object0[property0] == object1[property1])
: !valueExists(object1, property1);
}

function isAuthenticated() {
return request.auth != null && ('type' in request.auth.token);
return request.auth != null
&& ('type' in request.auth.token)
&& (
!valueExists(request.auth.token, 'disabled')
|| request.auth.token.disabled == false
);
}

function isAdmin() {
Expand Down Expand Up @@ -42,12 +57,6 @@ service cloud.firestore {
return get(/databases/$(databaseId)/documents/invitations/$(invitationId));
}

function isEqualOptional(object0, property0, object1, property1) {
return (property0 in object0)
? ((property1 in object1) && object0[property0] == object1[property1])
: !(property1 in object1);
}

match /invitations/{invitationId} {
function securityRelatedFieldsDidNotChange() {
return isEqualOptional(request.resource.data.user, 'type', resource.data.user, 'type')
Expand Down Expand Up @@ -107,22 +116,26 @@ service cloud.firestore {
match /users/{userId} {
function securityRelatedFieldsDidNotChange() {
return isEqualOptional(request.resource.data, 'type', resource.data, 'type')
&& isEqualOptional(request.resource.data, 'disabled', resource.data, 'disabled')
&& isEqualOptional(request.resource.data, 'organization', resource.data, 'organization');
}

function isAllowedUpdateWithinOrganization() {
return resource.data.type in ['patient']
? isOwnerOf(resource.data.organization)
: isOwnerOrClinicianOf(resource.data.organization) && resource.data.type in ['clinician'];
return valueExists(resource.data, 'type') ?
(
resource.data.type in ['patient']
? isOwnerOf(resource.data.organization)
: isOwnerOrClinicianOf(resource.data.organization) && resource.data.type in ['clinician']
) : false;
}

allow read: if isAdmin()
|| (resource == null && isAuthenticated())
|| (request.auth != null && request.auth.uid == userId)
|| isOwnerOrClinicianOf(resource.data.organization);
|| (resource == null && isAuthenticated())
|| (resource != null && valueExists(resource.data, 'organization') && isOwnerOrClinicianOf(resource.data.organization));

allow create: if isAdmin()
|| (isUser(userId) && !('organization' in request.resource.data) && !('type' in request.resource.data));
|| (isUser(userId) && !valueExists(request.resource.data, 'organization') && !valueExists(request.resource.data, 'type'));

allow update: if isAdmin()
|| (securityRelatedFieldsDidNotChange() && (isUser(userId) || isAllowedUpdateWithinOrganization()));
Expand Down
17 changes: 17 additions & 0 deletions functions/models/src/functions/disableUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

import { z } from 'zod'

export const disableUserInputSchema = z.object({
userId: z.string(),
})

export type DisableUserInput = z.input<typeof disableUserInputSchema>

export type DisableUserOutput = undefined
17 changes: 17 additions & 0 deletions functions/models/src/functions/enableUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

import { z } from 'zod'

export const enableUserInputSchema = z.object({
userId: z.string(),
})

export type EnableUserInput = z.input<typeof enableUserInputSchema>

export type EnableUserOutput = undefined
2 changes: 2 additions & 0 deletions functions/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export * from './functions/customSeed.js'
export * from './functions/defaultSeed.js'
export * from './functions/deleteInvitation.js'
export * from './functions/deleteUser.js'
export * from './functions/disableUser.js'
export * from './functions/dismissMessage.js'
export * from './functions/enableUser.js'
export * from './functions/enrollUser.js'
export * from './functions/exportHealthSummary.js'
export * from './functions/getUsersInformation.js'
Expand Down
1 change: 1 addition & 0 deletions functions/models/src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class User extends UserRegistration {

constructor(input: {
type: UserType
disabled: boolean
organization?: string
dateOfBirth?: Date
clinician?: string
Expand Down
17 changes: 13 additions & 4 deletions functions/models/src/types/userRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const userRegistrationInputConverter = new Lazy(
new SchemaConverter({
schema: z.object({
type: z.nativeEnum(UserType),
disabled: optionalishDefault(z.boolean(), false),
organization: optionalish(z.string()),
dateOfBirth: optionalish(dateConverter.schema),
clinician: optionalish(z.string()),
Expand All @@ -34,6 +35,7 @@ export const userRegistrationInputConverter = new Lazy(
}),
encode: (object) => ({
type: object.type,
disabled: object.disabled,
organization: object.organization ?? null,
dateOfBirth:
object.dateOfBirth ? dateConverter.encode(object.dateOfBirth) : null,
Expand Down Expand Up @@ -62,15 +64,19 @@ export const userRegistrationConverter = new Lazy(
}),
)

export interface UserClaims {
type: UserType
organization?: string
}
export const userClaimsSchema = z.object({
type: z.nativeEnum(UserType),
organization: optionalish(z.string()),
disabled: optionalishDefault(z.boolean(), false),
})

export type UserClaims = z.output<typeof userClaimsSchema>

export class UserRegistration {
// Stored Properties

readonly type: UserType
readonly disabled: boolean
readonly organization?: string

readonly dateOfBirth?: Date
Expand All @@ -93,6 +99,7 @@ export class UserRegistration {
get claims(): UserClaims {
const result: UserClaims = {
type: this.type,
disabled: this.disabled,
}
if (this.organization !== undefined) {
result.organization = this.organization
Expand All @@ -104,6 +111,7 @@ export class UserRegistration {

constructor(input: {
type: UserType
disabled: boolean
organization?: string
dateOfBirth?: Date
clinician?: string
Expand All @@ -119,6 +127,7 @@ export class UserRegistration {
timeZone?: string
}) {
this.type = input.type
this.disabled = input.disabled
this.organization = input.organization
this.dateOfBirth = input.dateOfBirth
this.clinician = input.clinician
Expand Down
1 change: 1 addition & 0 deletions functions/src/functions/deleteInvitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describeWithEmulators('function: deleteInvitation', (env) => {
code: 'TESTCODE',
user: new UserRegistration({
type: UserType.patient,
disabled: false,
organization: 'stanford',
receivesAppointmentReminders: false,
receivesInactivityReminders: true,
Expand Down
91 changes: 91 additions & 0 deletions functions/src/functions/disableUser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

import { UserType } from '@stanfordbdhg/engagehf-models'
import { describeWithEmulators } from '../tests/functions/testEnvironment.js'

Check warning on line 10 in functions/src/functions/disableUser.test.ts

View workflow job for this annotation

GitHub Actions / Lint

`../tests/functions/testEnvironment.js` import should occur after import of `./disableUser.js`
import { expect } from 'chai'
import { disableUser } from './disableUser.js'

describeWithEmulators('function: disableUser', (env) => {
it('disables an enabled user', async () => {
const clinicianId = await env.createUser({
type: UserType.clinician,
organization: 'stanford',
})

const userId = await env.createUser({
type: UserType.patient,
organization: 'stanford',
clinician: clinicianId,
})

const userService = env.factory.user()

const originalUser = await userService.getUser(userId)
expect(originalUser).to.exist
expect(originalUser?.content.claims.disabled).to.be.false
expect(originalUser?.content.disabled).to.be.false

await env.call(
disableUser,
{ userId: userId },
{
uid: clinicianId,
token: {
type: UserType.clinician,
organization: 'stanford',
disabled: false,
},
},
)

const user = await userService.getUser(userId)
expect(user).to.exist
expect(user?.content.claims.disabled).to.be.true
expect(user?.content.disabled).to.be.true
})

it('keeps disabled users disabled', async () => {
const clinicianId = await env.createUser({
type: UserType.clinician,
organization: 'stanford',
})

const userId = await env.createUser({
type: UserType.patient,
organization: 'stanford',
clinician: clinicianId,
disabled: true,
})

const userService = env.factory.user()

const originalUser = await userService.getUser(userId)
expect(originalUser).to.exist
expect(originalUser?.content.claims.disabled).to.be.true
expect(originalUser?.content.disabled).to.be.true

await env.call(
disableUser,
{ userId: userId },
{
uid: clinicianId,
token: {
type: UserType.clinician,
organization: 'stanford',
disabled: false,
},
},
)

const user = await userService.getUser(userId)
expect(user).to.exist
expect(user?.content.claims.disabled).to.be.true
expect(user?.content.disabled).to.be.true
})
})
41 changes: 41 additions & 0 deletions functions/src/functions/disableUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

import {
disableUserInputSchema,
type DisableUserOutput,
} from '@stanfordbdhg/engagehf-models'
import { validatedOnCall } from './helpers.js'
import { UserRole } from '../services/credential/credential.js'
import { getServiceFactory } from '../services/factory/getServiceFactory.js'

export const disableUser = validatedOnCall(
'disableUser',
disableUserInputSchema,
async (request): Promise<DisableUserOutput> => {
const factory = getServiceFactory()
const credential = factory.credential(request.auth)
const userId = request.data.userId
const userService = factory.user()

await credential.checkAsync(
() => [UserRole.admin],
async () => {
const user = await userService.getUser(credential.userId)
return user?.content.organization !== undefined ?
[
UserRole.owner(user.content.organization),
UserRole.clinician(user.content.organization),
]
: []
},
)

await userService.disableUser(userId)
},
)
Loading
Loading