Repository: medplum/medplum
Pull request #6325: Adding project scope to .../invite endpoint
Author: login: finnbergquist
Labels: name: documentation
description: Improvements or additions to documentation
name: example
description: Applies to Medplum example apps and sample code
Created: 2025-04-08T21:20:48Z | Merged: 2025-04-18T21:04:42Z

1. Adding new scope field to /admin/project/:projectid/invite
2. Docs about when to use project vs server scoped Users
3. New check box on the InvitePage
4. FE and Server tests

resolves #6236

Linked issue #6236: Add project scoping to ...{projectId}/invite
[Invite Request](https://www.medplum.com/docs/sdk/core.inviterequest) should allow you to specify whether the User should be _Server_ vs _Project scoped_

At the moment, the way to invite a User as _project scoped_ is adding the externalId. This tends to be confusing for people.


See the logic [here](https://github.com/medplum/medplum/blob/f4e48a8b6e477b052f1e106233c018f2a34180ab/packages/server/src/admin/invite.ts#L148C3-L154C4)

Diff:
diff --git a/packages/app/src/admin/InvitePage.test.tsx b/packages/app/src/admin/InvitePage.test.tsx
index 16325d716b6..13b32f751be 100644
--- a/packages/app/src/admin/InvitePage.test.tsx
+++ b/packages/app/src/admin/InvitePage.test.tsx
@@ -173,6 +173,36 @@ describe('InvitePage', () => {
     expect(screen.getByTestId('success')).toBeInTheDocument();
   });
 
+  test('Invite project scoped user', async () => {
+    await setup('/admin/invite');
+    expect(await screen.findByText('Invite')).toBeInTheDocument();
+
+    await act(async () => {
+      fireEvent.change(screen.getByLabelText('Role'), {
+        target: { value: 'Practitioner' },
+      });
+      fireEvent.change(screen.getByLabelText('First Name *'), {
+        target: { value: 'Patty' },
+      });
+      fireEvent.change(screen.getByLabelText('Last Name *'), {
+        target: { value: 'Practitioner' },
+      });
+      fireEvent.change(screen.getByLabelText('Email *'), {
+        target: { value: 'pattypractitioner@example.com' },
+      });
+    });
+
+    await act(async () => {
+      fireEvent.click(screen.getByLabelText('Project scoped'));
+    });
+
+    await act(async () => {
+      fireEvent.click(screen.getByText('Invite'));
+    });
+
+    expect(screen.getByTestId('success')).toBeInTheDocument();
+  });
+
   test('Do not send email', async () => {
     await setup('/admin/invite');
     expect(await screen.findByText('Invite')).toBeInTheDocument();
diff --git a/packages/app/src/admin/InvitePage.tsx b/packages/app/src/admin/InvitePage.tsx
index 8c4c6766376..e3988465e9c 100644
--- a/packages/app/src/admin/InvitePage.tsx
+++ b/packages/app/src/admin/InvitePage.tsx
@@ -24,6 +24,7 @@ export function InvitePage(): JSX.Element {
         sendEmail: formData.sendEmail === 'on',
         accessPolicy,
         admin: formData.isAdmin === 'on',
+        scope: formData.isProjectScoped === 'on' ? 'project' : 'server',
       };
       medplum
         .invite(project?.id as string, body as InviteRequest)
@@ -89,6 +90,7 @@ export function InvitePage(): JSX.Element {
           </FormSection>
           <Checkbox name="sendEmail" label="Send email" defaultChecked={true} />
           <Checkbox name="isAdmin" label="Admin" />
+          <Checkbox name="isProjectScoped" label="Project scoped" />
           <Group justify="flex-end">
             <Button type="submit">Invite</Button>
           </Group>
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index c4d67ba8e13..0b689aed720 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -498,6 +498,7 @@ export interface InviteRequest {
   lastName: string;
   email?: string;
   externalId?: string;
+  scope?: 'project' | 'server';
   password?: string;
   sendEmail?: boolean;
   membership?: Partial<ProjectMembership>;
diff --git a/packages/docs/docs/access/admin.md b/packages/docs/docs/access/admin.md
index 6808000f987..7408da6b924 100644
--- a/packages/docs/docs/access/admin.md
+++ b/packages/docs/docs/access/admin.md
@@ -19,7 +19,7 @@ Project Admins have the following privileges:
   - [`Project`](/docs/api/fhir/medplum/project) - used to adjust [Project settings](/docs/access/projects#settings)
   - [`ProjectMembership`](/docs/api/fhir/medplum/projectmembership) - used to manage user registration and privileges (see: [User Management Guide](/docs/auth/user-management-guide))
   - [`UserSecurityRequest`](/docs/api/fhir/medplum/usersecurityrequest) - used to [send custom emails](/docs/auth/custom-emails#password-change-request-bot)
-  - [`User`](/docs/api/fhir/medplum/user) - only for [project scoped users](/docs/auth/user-management-guide#project-scoped-users)
+  - [`User`](/docs/api/fhir/medplum/user) - only for [project scoped users](/docs/auth/project-vs-server-scoped-users#project-scoped-users)
 - **Impersonate users** - Project Admins are allowed to make API calls [on behalf of other users](/docs/auth/methods/on-behalf-of)
 
 :::note Applying Access Policies to Admins
diff --git a/packages/docs/docs/access/projects.md b/packages/docs/docs/access/projects.md
index d34e351ddd1..c5a161c5d44 100644
--- a/packages/docs/docs/access/projects.md
+++ b/packages/docs/docs/access/projects.md
@@ -94,7 +94,7 @@ documentation for more information.
 | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
 | `superAdmin`                 | Whether this project is the super administrator project ([see above](#superadmin)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | `false` |
 | `features`                   | A list of optional features that are enabled for the project. Values related to access control include: <ul><li>`bots`: This [`Project`](/docs/api/fhir/medplum/project) is allowed to create and run [Bots](/docs/bots/bot-basics).</li><li>`email`: Bots in this project can [send emails](/docs/sdk/core.medplumclient.sendemail). </li><li>`cron`: This [`Project`](/docs/api/fhir/medplum/project) can run Bots on [CRON timers](https://www.medplum.com/docs/bots/bot-cron-job)</li><li>`google-auth-required`: [Google authentication](/docs/auth/methods/google-auth) is the only method allowed for this [`Project`](/docs/api/fhir/medplum/project)</li></ul> |         |
-| `defaultPatientAccessPolicy` | The default [`AccessPolicy`](/docs/access/access-policies) applied to all [Patient Users](/docs/auth/user-management-guide#project-scoped-users) invited to this [`Project`](/docs/api/fhir/medplum/project). This is required to enable [open patient registration](/docs/auth/open-patient-registration).                                                                                                                                                                                                                                                                                                                                                             |         |
+| `defaultPatientAccessPolicy` | The default [`AccessPolicy`](/docs/access/access-policies) applied to all [Patient Users](/docs/auth/project-vs-server-scoped-users#project-scoped-users) invited to this [`Project`](/docs/api/fhir/medplum/project). This is required to enable [open patient registration](/docs/auth/open-patient-registration).                                                                                                                                                                                                                                                                                                                                                             |         |
 
 ## Project Secrets
 
diff --git a/packages/docs/docs/api/auth/resetpassword.md b/packages/docs/docs/api/auth/resetpassword.md
index 1161f9d0829..881342dde7b 100644
--- a/packages/docs/docs/api/auth/resetpassword.md
+++ b/packages/docs/docs/api/auth/resetpassword.md
@@ -22,7 +22,7 @@ Please note that you may need to specify _projectId_ if your User is project sco
 | Field | Type | Required | Description |
 |-------|------|----------|-------------|
 | email | string | Yes | User's email address (3-72 characters) |
-| projectId | string | No | Project ID for project-scoped users. See [project scoped users](/docs/auth/user-management-guide#project-scoped-users) Omit for system-level users |
+| projectId | string | No | Project ID for project-scoped users. See [project scoped users](/docs/auth/project-vs-server-scoped-users#project-scoped-users) Omit for system-level users |
 | sendEmail | boolean | No | Whether to send Medplum labeled reset email (defaults to true) |
 | redirectUri | string | No | URI to redirect after password reset |
 | recaptchaSiteKey | string | No | reCAPTCHA site key for verification |
diff --git a/packages/docs/docs/api/project-admin/invite.md b/packages/docs/docs/api/project-admin/invite.md
index 3c5c117c8dd..aeab5144691 100644
--- a/packages/docs/docs/api/project-admin/invite.md
+++ b/packages/docs/docs/api/project-admin/invite.md
@@ -25,6 +25,7 @@ Invite a new user to the project. This will perform the following actions:
   lastName: string;
   email?: string;
   externalId?: string;
+  scope?: 'project' | 'server';
   password?: string;
   sendEmail?: boolean;
   membership?: Partial<ProjectMembership>;
@@ -38,6 +39,7 @@ Invite a new user to the project. This will perform the following actions:
 | `email`                 | The email address assigned to the [User](/docs/api/fhir/medplum/user). Used to identify users within each project                                                                                                                                                                                  |
 | `externalId`            | The unique id provided by external identity provider (if applicable). See [Using External Ids](/docs/auth/methods/external-ids)                                                                                                                                                                    |
 | `password`              | The [User's](/docs/api/fhir/medplum/user) password                                                                                                                                                                                                                                                 |
+| `scope`                 | The scope of the user. If `project`, the user will be scoped to the project. If `server`, the user will be a server scoped user. Defaults to `server` for Practitioners and `project` for Patients. See [server vs project scoped user guide](/docs/auth/project-vs-server-scoped-users)                                                                                                                               |
 | `sendEmail`             | If `true`, send an invite email to the user. If self-hosting, see our [guide on setting up SES](/docs/self-hosting/install-on-aws#setup-ses)                                                                                                                                                       |
 | `membership`            | Used to override any fields of the resulting [`ProjectMembership`](/docs/api/fhir/medplum/projectmembership) resource. Common use cases include: <ul><li>Setting [Access Policies](/docs/access/access-policies) upon invite </li><li>Overriding the default `ProjectMembership.profile`</li></ul> |
 
diff --git a/packages/docs/docs/app/admin-page/admin-page.md b/packages/docs/docs/app/admin-page/admin-page.md
index aa3cd60cf8c..a82ff28b890 100644
--- a/packages/docs/docs/app/admin-page/admin-page.md
+++ b/packages/docs/docs/app/admin-page/admin-page.md
@@ -16,7 +16,7 @@ The Users tab displays all of the [`Practitioner`](/docs/api/fhir/resources/prac
 
 ## Patients
 
-The Patients tab displays all of the [`Patient`](/docs/api/fhir/resources/patient) resources that are also a [`User`](/docs/api/fhir/medplum/user) in your project. It also allows you to invite new patients. For more details on the [`User`](/docs/api/fhir/medplum/user) and [`Patient`](/docs/api/fhir/resources/patient) resources see the [User Management Guide](/docs/auth/user-management-guide#project-scoped-users).
+The Patients tab displays all of the [`Patient`](/docs/api/fhir/resources/patient) resources that are also a [`User`](/docs/api/fhir/medplum/user) in your project. It also allows you to invite new patients. For more details on the [`User`](/docs/api/fhir/medplum/user) and [`Patient`](/docs/api/fhir/resources/patient) resources see the [User Management Guide](/docs/auth/user-management-guide).
 
 ## Clients
 
diff --git a/packages/docs/docs/auth/project-vs-server-scoped-users.mdx b/packages/docs/docs/auth/project-vs-server-scoped-users.mdx
new file mode 100644
index 00000000000..5f117a30914
--- /dev/null
+++ b/packages/docs/docs/auth/project-vs-server-scoped-users.mdx
@@ -0,0 +1,74 @@
+---
+sidebar_position: 11
+tags: [auth]
+---
+
+# Project vs Server Scoped Users
+
+
+### Server Scoped Users
+
+Server scoped [`Users`](/docs/api/fhir/medplum/user) can be used across multiple projects across the Medplum Server. Server scoped Users should be used for **developers and administrators** that need to interact with multiple projects (e.g. staging and production). By default, Practitioners are scoped to `server`.
+
+### Project Scoped Users
+
+Project scoped [`Users`](/docs/api/fhir/medplum/user) exist inside a single [`Project`](/docs/api/fhir/medplum/project). Project scoped Users should be used for **real practicing clinicians and patients** that will primarily interact with a single production project. By default, Patients are scoped to `project`. If you want to include a project scoped User in multiple projects, you will need to invite them to each [`Project`](/docs/api/fhir/medplum/project) separately. 
+
+You can specify the scope of a [User](/docs/api/fhir/medplum/user) by adding the `scope` parameter to the [Invite User](/docs/api/project-admin/invite) endpoint.
+
+```ts
+const response = await medplum.post('/admin/projects/:projectId/invite', {
+  resourceType: 'Practitioner',
+  firstName: 'Test',
+  lastName: 'User',
+  email: 'test@example.com',
+  scope: 'project' // or 'server'
+})
+```
+```mermaid  
+flowchart TD
+    subgraph MedplumServer["<div style='font-size:25px; font-weight:bold;'>Medplum Server</div>"]
+        %% Server‑Scoped User
+        SS1[<div style="text-align: center;">User<br><strong>Company Dev/Admin</strong><br><small><em style='color:gray;'>Server Scoped</em></small></div>]
+        SS1 --> PM1_SS1
+        SS1 --> PM2_SS1
+
+        %% Staging Project subgraph
+        subgraph StagingProject["<div style='font-size:20px; font-weight:bold;'>Staging Project</div>"]
+            SD1[<div style="text-align: center;">User<br><strong>Test Doctor 1</strong><br><small><em style='color:gray;'>Project Scoped</em></small></div>]
+            PM1[<div style="text-align: center;">ProjectMembership<br><small style='color:gray;'>Project: Staging Project<br>Profile: &lt;Practitioner&gt;</small></div>]
+            SD1 --> PM1
+
+            PAT1[<div style="text-align: center;">User<br><strong>Example Patient 1</strong><br><small><em style='color:gray;'>Project Scoped</em></small></div>]
+            PM_PAT1[<div style="text-align: center;">ProjectMembership<br><small style='color:gray;'>Project: Staging Project<br>Profile: &lt;Patient&gt;</small></div>]
+            PAT1 --> PM_PAT1
+
+            PM1_SS1[<div style="text-align: center;">ProjectMembership<br><small style='color:gray;'>Project: Staging Project<br>Profile: &lt;Practitioner&gt;</small></div>]
+        end
+
+        %% Production Project subgraph
+        subgraph ProdProject["<div style='font-size:20px; font-weight:bold;'>Production Project</div>"]
+            PAT2[<div style="text-align: center;">User<br><strong>John Doe</strong><br><small><em style='color:gray;'>Project Scoped</em></small></div>]
+            PM_PAT2[<div style="text-align: center;">ProjectMembership<br><small style='color:gray;'>Project: Production Project<br>Profile: &lt;Patient&gt;</small></div>]
+            PAT2 --> PM_PAT2
+
+            DR_SMITH[<div style="text-align: center;">User<br><strong>Dr. Alice Smith</strong><br><small><em style='color:gray;'>Project Scoped</em></small></div>]
+            DR_SMITH_PM[<div style="text-align: center;">ProjectMembership<br><small style='color:gray;'>Project: Production Project<br>Profile: &lt;Practitioner&gt;</small></div>]
+            DR_SMITH --> DR_SMITH_PM
+
+            PM2_SS1[<div style="text-align: center;">ProjectMembership<br><small style='color:gray;'>Project: Production Project<br>Profile: &lt;Practitioner&gt;</small></div>]
+        end
+    end
+
+    style MedplumServer fill:#f5f5f5,stroke:#888888,stroke-width:2px;
+
+```
+
+
+:::info
+For **hosted customers**, you will only have control over the User resources for your project scoped users, but hosted customers can access **server scoped users** from their [super admin project](/docs/self-hosting/super-admin-guide).
+:::
+
+:::note
+If you want to create a [custom email flow](/docs/auth/custom-emails) for your Practitioners, they will need to be invited as a Project Scoped Users so that the UserSecurityRequest resources are also scoped to the project.
+:::
diff --git a/packages/docs/docs/auth/user-management-guide/user-management-guide.md b/packages/docs/docs/auth/user-management-guide/user-management-guide.md
index 8f2eab70795..841f0107689 100644
--- a/packages/docs/docs/auth/user-management-guide/user-management-guide.md
+++ b/packages/docs/docs/auth/user-management-guide/user-management-guide.md
@@ -19,7 +19,7 @@ Medplum has several resources that represent user identities. The following reso
 ```mermaid
 
 graph TD
-	User1["<strong>User</strong><br/>email: foo@example.com"]
+	User1["<strong>User</strong><br/>email: foo＠example.com<br/><span style='color:grey'><em>Server Scoped</em></span>"]
 	PM1_1("<strong>ProjectMembership</strong><br/>admin: true")
 	PM1_2("<strong>ProjectMembership</strong><br/>admin: false")
 	PM2_2("<strong>ProjectMembership</strong><br/>accessPolicy: ['AccessPolicy/123']")
@@ -36,7 +36,7 @@ graph TD
     Bot["<strong>Bot</strong>"] --> PM2_3("<strong>ProjectMembership</strong><br/>accessPolicy: ['AccessPolicy/456']")
 	end
 	subgraph Project1
-    User2["<strong>User</strong><br/>email: bar@example.com"]
+    User2["<strong>User</strong><br/>email: bar＠example.com<br/><span style='color:grey'><em>Project Scoped</em></span>"]
     PM1_2 --> p4
 		p4[[Practitioner]]
 	  p5[[DiagnosticReport]]
@@ -57,22 +57,13 @@ graph TD
 
 The [User](/docs/api/fhir/medplum/user) resource is the main resource that represents digital identity during authentication.
 
-[Users](/docs/api/fhir/medplum/user) can have two different scopes:
+[Users](/docs/api/fhir/medplum/user) can have two different scopes, `server` or `project`:
 
-- Server scoped users
-- Project scoped users
+- [Server scoped users](/docs/auth/project-vs-server-scoped-users#server-scoped-users) tend to be administrators and developers that need to access multiple projects.
+- [Project scoped users](/docs/auth/project-vs-server-scoped-users#project-scoped-users) tend to be clinicians and patients that only need access to a single project.
 
-#### Server Scoped Users
+See our guide on [Project vs Server Scoped Users](/docs/auth/project-vs-server-scoped-users) for more details.
 
-Server scoped [`Users`](/docs/api/fhir/medplum/user) are typically used for `Practitioners`. Practitioners can be members of multiple projects (e.g. "staging" and "prod"), and having their `Users` at the server level allows them to easily sign into multiple projects.
-
-#### Project Scoped Users
-
-Project scoped [`Users`](/docs/api/fhir/medplum/user) only exist inside a project level, and cannot sign into server-level tools such as the [Medplum App](/docs/app). The most common use case is for [`Patient`](/docs/api/fhir/resources/patient) users. These users will have to be invited and enrolled separately to each[ `Project` ](/docs/api/fhir/medplum/project) they are a part of, and there will be no link between their identities across projects.
-
-This is desirable in multi-tenant use cases, where patients enrolled with one tenant should not be aware of other tenants.
-
-By default, the server scopes all users [`Users`](/docs/api/fhir/medplum/user) enrolled as with a [`Patient`](/docs/api/fhir/resources/patient) profile ([see below](#profiles)) as project-scoped users.
 
 ### Profiles
 
diff --git a/packages/docs/docs/self-hosting/project-settings.md b/packages/docs/docs/self-hosting/project-settings.md
index b88e504d171..1fb5f70aa70 100644
--- a/packages/docs/docs/self-hosting/project-settings.md
+++ b/packages/docs/docs/self-hosting/project-settings.md
@@ -14,7 +14,7 @@ Additional details are available in the full [`Project` resource schema](/docs/a
 | `superAdmin`                 | Users belonging to a Project with this flag are granted [Super Admin](/docs/access/projects#superadmin) access to the server. Multiple Projects can have this set.                                                                                                                                          | `false` |
 | `checkReferencesOnWrite`     | If `true`, the the server will reject any create or write operations to FHIR resources with a reference to a resource that does not exist.                                                                                                                                                                  | `false` |
 | `features`                   | A list of optional features that are enabled for the project. Possible values are listed [below](#project-feature-flags).                                                                                                                                                                                   |         |
-| `defaultPatientAccessPolicy` | The default [`AccessPolicy`](/docs/access/access-policies) applied to all [Patient Users](/docs/auth/user-management-guide#project-scoped-users) invited to this [`Project`](/docs/api/fhir/medplum/project). This is required to enable [open patient registration](/docs/auth/open-patient-registration). |         |
+| `defaultPatientAccessPolicy` | The default [`AccessPolicy`](/docs/access/access-policies) applied to all [Patient Users](/docs/auth/project-vs-server-scoped-users#project-scoped-users) invited to this [`Project`](/docs/api/fhir/medplum/project). This is required to enable [open patient registration](/docs/auth/open-patient-registration). |         |
 | `link`                       | Additional Projects whose [contents should be accessible](/docs/access/projects#project-linking) to users in the current Project.                                                                                                                                                                           |         |
 | `defaultProfile`             | [Resource profiles](http://hl7.org/fhir/R4/profiling.html#resources) that will be added to resources written in the Project that do not specify a profile directly. This enables automatic custom resource validation.                                                                                      |         |
 | `setting`                    | Arbitrary key-value pairs available to anyone in the Project, can be set by Project Admins.                                                                                                                                                                                                                 |         |
diff --git a/packages/examples/src/auth/user-management-guide.ts b/packages/examples/src/auth/user-management-guide.ts
index 1b932cac150..17a045714f3 100644
--- a/packages/examples/src/auth/user-management-guide.ts
+++ b/packages/examples/src/auth/user-management-guide.ts
@@ -97,6 +97,7 @@ await medplum.post('admin/projects/example-project-id/invite', {
   lastName: 'Smith',
   email: 'alicesmith@example.com',
   admin: true,
+  scope: 'server',
 });
 // end-block inviteNewAdminTs
 
diff --git a/packages/server/src/admin/invite.test.ts b/packages/server/src/admin/invite.test.ts
index 725dcf0c050..2e017a359f2 100644
--- a/packages/server/src/admin/invite.test.ts
+++ b/packages/server/src/admin/invite.test.ts
@@ -760,4 +760,71 @@ describe('Admin Invite', () => {
       'User is already a member of this project with a different profile'
     );
   });
+
+  test('Invite project scoped user', async () => {
+    const { project, accessToken } = await withTestContext(() =>
+      registerNew({
+        firstName: 'Alice',
+        lastName: 'Smith',
+        projectName: 'Alice Project',
+        email: `alice${randomUUID()}@example.com`,
+        password: 'password!@#',
+      })
+    );
+
+    // Second, Alice invites Bob to the project
+    const bobEmail = `bob${randomUUID()}@example.com`;
+    const res = await request(app)
+      .post('/admin/projects/' + project.id + '/invite')
+      .set('Authorization', 'Bearer ' + accessToken)
+      .send({
+        resourceType: 'Practitioner',
+        firstName: 'Bob',
+        lastName: 'Jones',
+        email: bobEmail,
+        scope: 'project',
+      });
+
+    expect(res.status).toBe(200);
+    const res2 = await request(app)
+      .get('/fhir/R4/User?email=' + bobEmail)
+      .set('Authorization', 'Bearer ' + accessToken);
+    expect(res2.status).toBe(200);
+    const user = res2.body.entry[0].resource;
+    expect(user.resourceType).toBe('User');
+    expect(user.project.reference).toBe(getReferenceString(project));
+  });
+
+  test('Invite server scoped User, and check that the User is not accessible from the project', async () => {
+    const { project, accessToken } = await withTestContext(() =>
+      registerNew({
+        firstName: 'Alice',
+        lastName: 'Smith',
+        projectName: 'Alice Project',
+        email: `alice${randomUUID()}@example.com`,
+        password: 'password!@#',
+      })
+    );
+
+    // Second, Alice invites Bob to the project
+    const bobEmail = `bob${randomUUID()}@example.com`;
+    const res = await request(app)
+      .post('/admin/projects/' + project.id + '/invite')
+      .set('Authorization', 'Bearer ' + accessToken)
+      .send({
+        resourceType: 'Practitioner',
+        firstName: 'Bob',
+        lastName: 'Jones',
+        email: bobEmail,
+        scope: 'server',
+      });
+
+    expect(res.status).toBe(200);
+    const res2 = await request(app)
+      .get('/fhir/R4/User?email=' + bobEmail)
+      .set('Authorization', 'Bearer ' + accessToken);
+    expect(res2.status).toBe(200);
+    expect(res2.body.resourceType).toBe('Bundle');
+    expect(res2.body.entry).toBeUndefined();
+  });
 });
diff --git a/packages/server/src/admin/invite.ts b/packages/server/src/admin/invite.ts
index 3ce77c1d9d3..182ebe1d61e 100644
--- a/packages/server/src/admin/invite.ts
+++ b/packages/server/src/admin/invite.ts
@@ -139,13 +139,13 @@ export async function inviteUser(request: ServerInviteRequest): Promise<ServerIn
 }
 
 async function createUser(request: ServerInviteRequest): Promise<WithId<User>> {
-  const { firstName, lastName, externalId } = request;
+  const { firstName, lastName, externalId, scope } = request;
   const email = request.email?.toLowerCase();
   const password = request.password ?? generateSecret(16);
   const passwordHash = await bcryptHashPassword(password);
 
   let project: Reference<Project> | undefined = undefined;
-  if (request.resourceType === 'Patient' || externalId) {
+  if (request.resourceType === 'Patient' || externalId || scope === 'project') {
     // Users can optionally be scoped to a project.
     // We force users to be scoped to a project if:
     // 1) They are a patient


Reviews:
user: login: rahul1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T23:11:24Z
user: login: finnbergquist
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T23:20:13Z
user: login: codyebberson
body: The changes to `packages/server/src/admin/invite.ts` look good to me, that's the only part I really care about.  I'll leave the docs portion up to @rahul1 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T23:38:00Z
user: login: finnbergquist
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T00:01:51Z
user: login: finnbergquist
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:23:30Z
user: login: rahul1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T17:19:50Z
user: login: rahul1
body: Some minor docs comments, provisionally approved
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-17T17:25:40Z

Review comments:
diff_hunk: @@ -38,6 +39,7 @@ Invite a new user to the project. This will perform the following actions:
 | `email`                 | The email address assigned to the [User](/docs/api/fhir/medplum/user). Used to identify users within each project                                                                                                                                                                                  |
 | `externalId`            | The unique id provided by external identity provider (if applicable). See [Using External Ids](/docs/auth/methods/external-ids)                                                                                                                                                                    |
 | `password`              | The [User's](/docs/api/fhir/medplum/user) password                                                                                                                                                                                                                                                 |
+| `scope`                 | The scope of the user. If `project`, the user will be scoped to the project. If `system`, the user will be a system user. Defaults to `server`. See [server vs project scoped user guide](/docs/auth/project-vs-server-scoped-users)                                                                                                                               |
path: packages/docs/docs/api/project-admin/invite.md
user: login: rahul1
body: @finnbergquist there seems to be some inconsistency between the term `system` and `server` scope. 

So far we've been using the term `server`, but I'm open to changing it if we have a good reason. We just have to make sure we use consistent terminology everywhere
created_at: 2025-04-14T23:00:22Z
author_association: MEMBER
path: packages/docs/docs/auth/project-vs-server-scoped-users.mdx
user: login: rahul1
body: What was the rationale for making this a new doc vs. modifying [this doc](https://www.medplum.com/docs/auth/user-management-guide) / 

I'm open to making this a separate doc, since many developers miss this. But we should avoid duplicative content and making the discoverability problem worse
created_at: 2025-04-14T23:10:57Z
author_association: MEMBER
diff_hunk: @@ -38,6 +39,7 @@ Invite a new user to the project. This will perform the following actions:
 | `email`                 | The email address assigned to the [User](/docs/api/fhir/medplum/user). Used to identify users within each project                                                                                                                                                                                  |
 | `externalId`            | The unique id provided by external identity provider (if applicable). See [Using External Ids](/docs/auth/methods/external-ids)                                                                                                                                                                    |
 | `password`              | The [User's](/docs/api/fhir/medplum/user) password                                                                                                                                                                                                                                                 |
+| `scope`                 | The scope of the user. If `project`, the user will be scoped to the project. If `system`, the user will be a system user. Defaults to `server`. See [server vs project scoped user guide](/docs/auth/project-vs-server-scoped-users)                                                                                                                               |
path: packages/docs/docs/api/project-admin/invite.md
user: login: finnbergquist
body: good call out
created_at: 2025-04-14T23:20:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,13 +139,13 @@ export async function inviteUser(request: ServerInviteRequest): Promise<ServerIn
 }
 
 async function createUser(request: ServerInviteRequest): Promise<WithId<User>> {
-  const { firstName, lastName, externalId } = request;
+  const { firstName, lastName, externalId, scope } = request;
   const email = request.email?.toLowerCase();
   const password = request.password ?? generateSecret(16);
   const passwordHash = await bcryptHashPassword(password);
 
   let project: Reference<Project> | undefined = undefined;
-  if (request.resourceType === 'Patient' || externalId) {
+  if (request.resourceType === 'Patient' || externalId || scope === 'project') {
path: packages/server/src/admin/invite.ts
user: login: codyebberson
body: 👍 
created_at: 2025-04-14T23:37:34Z
author_association: MEMBER
path: packages/docs/docs/auth/project-vs-server-scoped-users.mdx
user: login: finnbergquist
body: @rahul1, I thought that [User Management Guide](https://www.medplum.com/docs/auth/user-management-guide) was pretty crowded already and wanted to create space for more documentation to come for this topic.

I can do some work to minimize duplicate info and hyperlink things to this new page.
created_at: 2025-04-15T00:01:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,7 +19,7 @@ Medplum has several resources that represent user identities. The following reso
 ```mermaid
 
 graph TD
-	User1["<strong>User</strong><br/>email: foo@example.com"]
+	User1["<strong>User</strong><br/>email: foo＠example.com<br/><span style='color:grey'><em>Server Scoped</em></span>"]
path: packages/docs/docs/auth/user-management-guide/user-management-guide.md
user: login: finnbergquist
body: I know this seems like a ridiculous change, but the other @ symbol doesn't seem to work with mermaid.
created_at: 2025-04-16T21:23:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,22 +57,11 @@ graph TD
 
 The [User](/docs/api/fhir/medplum/user) resource is the main resource that represents digital identity during authentication.
 
-[Users](/docs/api/fhir/medplum/user) can have two different scopes:
+[Users](/docs/api/fhir/medplum/user) can have two different scopes, `server` or `project`:
path: packages/docs/docs/auth/user-management-guide/user-management-guide.md
user: login: rahul1
body: I suggest putting some example use cases for each user type right here in this paragraph. 

"Project Scoped users tend to be..."
"Server scoped users tend to be..."

and repeating these use cases on the dedicated page.

"See [our guide](link) on user scoping for more information. "
created_at: 2025-04-17T17:19:50Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-04-08T21:20:52Z
body: [vc]: #JbEXCTQOg4U2sbt8F30rmM11WivbZGGKhpAqld4rFBw=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJtZWRwbHVtLXN0b3J5Ym9vayIsInJvb3REaXJlY3RvcnkiOiJwYWNrYWdlcy9yZWFjdCIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9tZWRwbHVtL21lZHBsdW0tc3Rvcnlib29rL0o0UTRWZ215d1l0YXk1ZlcxelRQc1Y0Zm5UMjgiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiIn19LHsibmFtZSI6Im1lZHBsdW0td3d3Iiwicm9vdERpcmVjdG9yeSI6InBhY2thZ2VzL2RvY3MiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbWVkcGx1bS9tZWRwbHVtLXd3dy9GV2VKY1ZmWnZNZkpqdUFDNWdKaTE5UjhmUW5HIiwicHJldmlld1VybCI6Im1lZHBsdW0td3d3LWdpdC1maW5uLWludml0ZS1wcm9qZWN0LXNjb3BlZC11c2VyLW1lZHBsdW0udmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJtZWRwbHVtLXd3dy1naXQtZmlubi1pbnZpdGUtcHJvamVjdC1zY29wZWQtdXNlci1tZWRwbHVtLnZlcmNlbC5hcHAifX0seyJuYW1lIjoibWVkcGx1bS1hcHAiLCJyb290RGlyZWN0b3J5IjoicGFja2FnZXMvYXBwIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL21lZHBsdW0vbWVkcGx1bS1hcHAvQThqamlOajMxRHBNc2ROM1R3aEFtYXFuaVhxNSIsInByZXZpZXdVcmwiOiIiLCJuZXh0Q29tbWl0U3RhdHVzIjoiU0tJUFBFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifX0seyJuYW1lIjoibWVkcGx1bS1wcm92aWRlciIsInJvb3REaXJlY3RvcnkiOiJleGFtcGxlcy9tZWRwbHVtLXByb3ZpZGVyLyIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9tZWRwbHVtL21lZHBsdW0tcHJvdmlkZXIvRXRQSjR2cDFXOFhFUmF5TkpRMzNkVFhrNVFoZiIsInByZXZpZXdVcmwiOiIiLCJuZXh0Q29tbWl0U3RhdHVzIjoiU0tJUFBFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifX1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **medplum-www** | ✅ Ready ([Inspect](https://vercel.com/medplum/medplum-www/FWeJcVfZvMfJjuAC5gJi19R8fQnG)) | [Visit Preview](https://medplum-www-git-finn-invite-project-scoped-user-medplum.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/medplum-www-git-finn-invite-project-scoped-user-medplum.vercel.app?via=pr-comment-feedback-link) | Apr 18, 2025 8:38pm |

<details><summary>3 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **medplum-app** | ⬜️ Skipped ([Inspect](https://vercel.com/medplum/medplum-app/A8jjiNj31DpMsdN3TwhAmaqniXq5)) |  |  | Apr 18, 2025 8:38pm |
| **medplum-provider** | ⬜️ Skipped ([Inspect](https://vercel.com/medplum/medplum-provider/EtPJ4vp1W8XERayNJQ33dTXk5Qhf)) |  |  | Apr 18, 2025 8:38pm |
| **medplum-storybook** | ⬜️ Skipped ([Inspect](https://vercel.com/medplum/medplum-storybook/J4Q4VgmywYtay5fW1zTPsV4fnT28)) |  |  | Apr 18, 2025 8:38pm |
</details>





author_association: NONE
user: login: finnbergquist
created_at: 2025-04-08T22:03:41Z
body: <img width="993" alt="Screenshot 2025-04-08 at 3 02 54 PM" src="https://github.com/user-attachments/assets/e7016fb0-c465-4a2c-8603-5e279f68972f" />

author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-04-18T20:51:40Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=medplum_medplum&pullRequest=6325) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=medplum_medplum&pullRequest=6325&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=medplum_medplum&pullRequest=6325&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=medplum_medplum&pullRequest=6325&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=medplum_medplum&pullRequest=6325&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=medplum_medplum&pullRequest=6325&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=medplum_medplum&pullRequest=6325)


author_association: NONE