Skip to main content
Glama
accesspolicy.ts11.1 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { ProfileResource, WithId } from '@medplum/core'; import { badRequest, createReference, getReferenceString, isResource, isString, OperationOutcomeError, projectAdminResourceTypes, resolveId, } from '@medplum/core'; import type { AccessPolicy, AccessPolicyIpAccessRule, AccessPolicyResource, Project, ProjectMembership, ProjectMembershipAccess, Reference, } from '@medplum/fhirtypes'; import { getLogger } from '../logger'; import type { AuthState } from '../oauth/middleware'; import { getSystemRepo, Repository } from './repo'; import { applySmartScopes } from './smart'; export type PopulatedAccessPolicy = AccessPolicy & { resource: AccessPolicyResource[] }; /** * Creates a repository object for the user auth state. * Individual instances of the Repository class manage access rights to resources. * Login instances contain details about user compartments. * This method ensures that the repository is setup correctly. * @param authState - The authentication state. * @param extendedMode - Optional flag to enable extended mode for custom Medplum properties. * @returns A repository configured for the login details. */ export async function getRepoForLogin(authState: AuthState, extendedMode?: boolean): Promise<Repository> { const { login, membership: realMembership, onBehalfOfMembership } = authState; const membership = onBehalfOfMembership ?? realMembership; const systemRepo = getSystemRepo(); const accessPolicy = await getAccessPolicyForLogin(authState); const project = await systemRepo.readReference(membership.project); const allowedProjects: WithId<Project>[] = [project]; if (project.link) { const linkedProjectRefs: Reference<Project>[] = []; for (const link of project.link) { if (link.project) { linkedProjectRefs.push(link.project); } } const linkedProjectsOrError = await systemRepo.readReferences<Project>(linkedProjectRefs); for (let i = 0; i < linkedProjectsOrError.length; i++) { const linkedProjectOrError = linkedProjectsOrError[i]; if (isResource(linkedProjectOrError)) { allowedProjects.push(linkedProjectOrError); } else { // Ignore missing; if a super admin creates a project link to a non-existent project, // searching it would be a no-op. getLogger().debug('Linked project not found', { project: linkedProjectRefs[i] }); } } } return new Repository({ projects: allowedProjects, currentProject: project, author: realMembership.profile as Reference, remoteAddress: login.remoteAddress, superAdmin: project.superAdmin, projectAdmin: membership.admin, accessPolicy, strictMode: project.strictMode, extendedMode, checkReferencesOnWrite: project.checkReferencesOnWrite, validateTerminology: project.features?.some((f) => f === 'validate-terminology'), onBehalfOf: authState.onBehalfOf ? createReference(authState.onBehalfOf) : undefined, }); } /** * Returns the access policy for the user auth state. * @param authState - The authentication state. * @returns The finalized access policy. */ export async function getAccessPolicyForLogin(authState: AuthState): Promise<AccessPolicy | undefined> { const { project, login } = authState; const membership = authState.onBehalfOfMembership ?? authState.membership; let accessPolicy = await buildAccessPolicy(membership); if (login.scope) { // If the login specifies SMART scopes, // then set the access policy to use those scopes accessPolicy = applySmartScopes(accessPolicy, login.scope); } // Apply project admin access policies // This includes ensuring no admin rights for non-admins // and restricted access for admins accessPolicy = applyProjectAdminAccessPolicy(project, membership, accessPolicy); return accessPolicy; } /** * Builds a parameterized compound access policy. * @param membership - The user project membership. * @returns The parameterized compound access policy. */ export async function buildAccessPolicy(membership: ProjectMembership): Promise<PopulatedAccessPolicy> { const access: ProjectMembershipAccess[] = []; if (membership.accessPolicy) { access.push({ policy: membership.accessPolicy }); } if (membership.access) { access.push(...membership.access); } let compartment: Reference | undefined = undefined; const resourcePolicies: AccessPolicyResource[] = []; const ipAccessRules: AccessPolicyIpAccessRule[] = []; const accessPolicyMap = new Map<string, AccessPolicy>(); for (const entry of access) { const replaced = await buildAccessPolicyResources( entry, membership.profile as Reference<ProfileResource>, accessPolicyMap ); if (replaced.compartment) { compartment = replaced.compartment; } if (replaced.resource) { for (const resourcePolicy of replaced.resource) { if (!resourcePolicy.interaction && resourcePolicy.readonly) { resourcePolicy.interaction = ['search', 'read', 'history', 'vread']; } resourcePolicies.push(resourcePolicy); } } if (replaced.ipAccessRule) { ipAccessRules.push(...replaced.ipAccessRule); } } if (!membership?.access?.length && !membership.accessPolicy) { // Preserve legacy behavior of null access policy // TODO: This should be removed in future release when access policies are required resourcePolicies.push({ resourceType: '*' }); } addDefaultResourceTypes(resourcePolicies); return { resourceType: 'AccessPolicy', basedOn: access.map((a) => a.policy), compartment, resource: resourcePolicies, ipAccessRule: ipAccessRules.length ? ipAccessRules : undefined, }; } /** * Reads an access policy and replaces all variables. * @param access - The access policy and parameters. * @param profile - The user profile. * @param accessPolicyMap - Map of already-fetched access policies to avoid redundant lookups. * @returns The AccessPolicy with variables resolved. */ async function buildAccessPolicyResources( access: ProjectMembershipAccess, profile: Reference<ProfileResource>, accessPolicyMap: Map<string, AccessPolicy> ): Promise<AccessPolicy> { const systemRepo = getSystemRepo(); const accessPolicyReference = access.policy; const policyReferenceString = getReferenceString(accessPolicyReference); if (!isString(policyReferenceString)) { throw new Error('Access policy reference is required'); } let original = accessPolicyMap.get(policyReferenceString); if (!original) { try { original = await systemRepo.readReference(accessPolicyReference); accessPolicyMap.set(policyReferenceString, original); } catch (_error) { // Intentionally catch and rethrow with a generic error message for security // (don't expose access policy details during login) throw new OperationOutcomeError( badRequest( 'Cannot authenticate: Invalid access policy configuration. Please contact your administrator to update your project membership.' ) ); } } const params = access.parameter || []; params.push({ name: 'profile', valueReference: profile }); if (!params.find((p) => p.name === 'patient')) { params.push({ name: 'patient', valueReference: profile }); } let json = JSON.stringify(original); for (const param of params) { if (param.valueString) { json = json.replaceAll(`%${param.name}`, param.valueString); } else if (param.valueReference) { json = json.replaceAll(`%${param.name}.id`, resolveId(param.valueReference) as string); json = json.replaceAll(`%${param.name}`, param.valueReference.reference as string); } } return JSON.parse(json) as AccessPolicy; } /** * Adds default resource types to the access policy. * Once upon a time, all users automatically had access to "system" resource types such as StructureDefinition. * But now, users must have explicit access to these resource types. * Unfortunately, there are many clients that depend on this behavior. * So, we add these resource types to the access policy if they are not already present. * @param resourcePolicies - The existing set of resource policies. */ function addDefaultResourceTypes(resourcePolicies: AccessPolicyResource[]): void { const defaultResourceTypes = ['SearchParameter', 'StructureDefinition']; for (const resourceType of defaultResourceTypes) { if (!resourcePolicies.find((r) => r.resourceType === resourceType)) { resourcePolicies.push({ resourceType, readonly: true, }); } } } /** * Updates the access policy to include project admin rules. * This includes ensuring no admin rights for non-admins and restricted access for admins. * @param project - The project. * @param membership - The active project membership. * @param accessPolicy - The existing access policy. * @returns Updated access policy with all project admin rules applied. */ function applyProjectAdminAccessPolicy( project: Project, membership: ProjectMembership, accessPolicy: PopulatedAccessPolicy ): PopulatedAccessPolicy { if (project.superAdmin) { for (const adminResourceType of projectAdminResourceTypes) { if (!accessPolicy.resource.some((r) => r.resourceType === adminResourceType)) { accessPolicy.resource.push({ resourceType: adminResourceType }); } } } else if (membership.admin) { // If the user is a project admin, // then grant limited access to the project admin resource types accessPolicy.resource = accessPolicy.resource?.filter( (r) => !projectAdminResourceTypes.includes(r.resourceType as string) ); accessPolicy.resource.push({ resourceType: 'Project', criteria: `Project?_id=${resolveId(membership.project)}`, readonlyFields: ['features', 'link', 'systemSetting'], hiddenFields: ['superAdmin', 'systemSecret', 'strictMode'], }); if (project.link) { accessPolicy.resource.push({ resourceType: 'Project', criteria: `Project?_id=${project.link.map((link) => resolveId(link.project)).join(',')}`, readonly: true, hiddenFields: ['superAdmin', 'setting', 'systemSetting', 'secret', 'systemSecret', 'strictMode'], }); } accessPolicy.resource.push({ resourceType: 'ProjectMembership', readonlyFields: ['project', 'user'], }); accessPolicy.resource.push({ resourceType: 'UserSecurityRequest', readonly: true, }); accessPolicy.resource.push({ resourceType: 'User', hiddenFields: ['passwordHash', 'mfaSecret'], readonlyFields: ['email', 'emailVerified', 'mfaEnrolled', 'project'], }); } else { // Remove any references to project admin resource types accessPolicy.resource = accessPolicy.resource?.filter( (r) => !projectAdminResourceTypes.includes(r.resourceType as string) ); } return accessPolicy; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server