@excopartners/power-data-lib
v2.0.6
Published
This package is designed to be a utility library to connecting to the Microsoft Dynamics / Power Apps 365 Web API. It is part of the Exco Partners Experience Runtime. It specifically:
Downloads
670
Readme
Exco Partners - Power Data Lib
This package is designed to be a utility library to connecting to the Microsoft Dynamics / Power Apps 365 Web API. It is part of the Exco Partners Experience Runtime. It specifically:
- Simplified Web API calls
- Creates an abstraction layer for tabled oriented data
- Creates a framework for row level security
The examples below assume a web application like Next.js where the data access is managed on the server side.
Install our Experience Runtime
First install the experience runtime libraries:
Run the following:
npm install @excopartners/power-portal @excopartners/power-data-lib @excopartners/utils
Setting up Dynamics parameters
For this demo we will connect to our Dynamics Sandbox platform. We will add these settings into environment files.
This setup assumed the Dynamics Sandbox environment is already setup with the service principle refered to below.
Create a new environment file called
.env
Add the following lines to the
.env
fileDATAVERSE_CRM=https://xxxxx-sandbox.crm6.dynamics.com/ DATAVERSE_TENANT_ID=XXXXXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX DATAVERSE_CLIENT_ID=XXXXXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
Create another environment file called
.env.local
Add the following line to the
.env.local
fileDATAVERSE_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
We seperate these files for one very important reason. The .env
file holds all the common environment settings and is typically checked into a source code repository. The .env.local
file must NEVER be checked into source code as we use that to store secrets, like DATAVERSE_CLIENT_SECRET.
The data we have added specified:
- DATAVERSE_CRM: The url to our Sandbox Dynamics environment
- DATAVERSE_TENANT_ID: Our Azure Tenent that will be used for Authentication.
- DATAVERSE_CLIENT_ID: The Service Principle / App Registration that will be used for Authentication
- DATAVERSE_CLIENT_SECRET: The password for the Service Principle
Connecting to Dynamics
Next we will create a helper file that will help us create an authenticated API client to access Dynamics.
Create a new folder called
lib
we will add all our code and service here. The full path will be/src/lib
Create a new file in the
lib
folder calledDynamicsApi.ts
. Add the following code:import { Aut, DynamicsApi } from "@excopartners/power-data-lib"; // Create an authenticator for Dynamics export const dynamicsAuth = new Auth( process.env.DATAVERSE_CLIENT_ID || "client_id_not_set", process.env.DATAVERSE_CLIENT_SECRET || "client_secret_not_set", process.env.DATAVERSE_TENANT_ID || "tenant_id_not_set", [ process.env.DATAVERSE_CRM + "/.default" ] ); export const DYNAMICS_API_URL = process.env.DATAVERSE_CRM || "https://crm_url_not_set"; export const PUBLISHER_PREFIX: string = "exco_"; export const createDynamicsApiClient = () => new DynamicsApi(dynamicsAuth, DYNAMICS_API_URL); const dynamicsApi = createDynamicsApiClient(); export default dynamicsApi;
This file pulls the values from the .env*
files, creates an Authenticator called dynamicsAuth
and then creates a Dynamics API client called dynamicsApi
. We will later use this client to access data.
Fetching data from Dynamics
Next we will pull some data from Dynamics. For the demo, we will pull some Contact record data. In this example, assume we are building an API endpoint/route in next.js.
Return to the API file we created in Part 3.
/src/app/api/contacts/route.ts
Change the content of the file to be as follows:
import dynamicsApi from "@/lib/DynamicsApi"; import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { //Get the top set of contact records from dynamics const contacts = await dynamicsApi.dynamicsWebApi.retrieveMultiple({ collection: "contacts" }); //Return the value proprty of the returned contacts return NextResponse.json(contacts.value, {status: 200}); }
This will pull the top 2 contacts from Dynamics.
Restart the server:
npm run dev
Check the data returned from the API: http://localhost:3000/api/contacts
We we now check the page we made in Part 3, we can see the title is pullign the name of the first contact in Dynamics. See: http://localhost:3000
Making it easier to fetch data
The statement we used above to pull the data from dynamics is quite verbose. To make our code cleaner to write, read and maintain, we will create a helper file to access the Contacts table.
- Create a new folder called
backend/dataverse
. The full path will besrc/lib/backend/dataverse
. - Create a new file called
ContactRepository.ts
. This will be our helper file to access the Contacts table - Add the following code the the
ContactRepository.ts
file
ContactRepository.ts
import dynamicsApi from "@/lib/DynamicsApi";
import { DataverseDto, DataverseRepository, SessionUser } from "@excopartners/power-data-lib";
export const CONTACT_SELECT: string[] = [
"firstname",
"lastname",
"emailaddress1",
];
~/* * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* M A P P I N G
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * */
export interface ContactDto extends DataverseDto {
id: string
firstname: string;
lastname: string;
email?: string
}
export interface ContactForm extends Omit<ContactDto, "id"> {
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* R E P O C L A S S D E F I N I T I O N
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * */
export class ContactRepository extends DataverseRepository<ContactDto, any, any> {
// The table name in Dataverse
_collection = "contacts";
//The fields to select from the table
_select = CONTACT_SELECT;
_expand = undefined;
_orderby = undefined;
mapDto(data: any): ContactDto {
return mapContactDto(data);
}
}
export function mapContactDto(data: any): ContactDto {
return {
id: data.contactid,
firstname: data.firstname,
lastname: data.lastname,
email: data.emailaddress1,
};
}
/* * * * * * * * * * * * * * * * * * * * * * * *
*
* R E P O E X P O R T
*
* * * * * * * * * * * * * * * * * * * * * * * */
export default function contactRepository(session: SessionUser) {
const contactRepository = new ContactRepository(
{
session: session,
hasReadAccess: async () => true,
hasUpdateAccess: async () => false,
hasDeleteAccess: async () => false,
hasCreateAccess: async () => false
},
dynamicsApi
);
return contactRepository;
}
There is a bit in this file but it is all boiler plate that once setup, makes the rest of our code much cleaner.
It extends our DataverseRepository abscract class which brings in heaps of helper functions to access data. It also does some mapping messy Dynamics data field names to a nice strictly typed Typescript definition ContactDto
.
Now let's go back to our API file from Part 3. /src/app/api/users/route.ts
and change the code that accessed Dynamics from
//Get the top 2 contact records from dynamics
const contacts = await dynamicsApi.dynamicsWebApi.retrieveMultiple({
collection: "contacts",
top: 2
});
return NextResponse.json(contacts.value, {status: 200});
To:
const contacts = await contactRepository({}).fetchAll();
return NextResponse.json(contacts, {status: 200});
Security
The Digital Runtime libraries include support record-level security. This is a very important part of the library and must be handled carefully to make sure we don't create data vulnerabilities.
The security model takes a pessimistic view of accessing data and only allows access if the programmer explicitly permits it.
The underlying App User
that has access to the database, which we set up in Part 4 with the DATAVERSE_CLIENT_ID
setting, has, generally, access to all the data. For accessing data upstream of our application, ie. to the public internet, we need to tie the authenticated user to the data access layer so we can introduce security at the lowest level possible. This is the same method used by Power Pages.
Open up the ContactRepository from Part 4 and look for the section that exports the repository:
export default function contactRepository(session: SessionUser) {
const contactRepository = new ContactRepository(
{
session: session,
hasReadAccess: async () => true,
hasUpdateAccess: async () => false,
hasDeleteAccess: async () => false,
hasCreateAccess: async () => false
},
dynamicsApi
);
return contactRepository;
}
This code has four required parameters defining methods to confirm access across Read, Update, Delete and Create. The default is false
, meaning no access in all cases except for Read, which we set to true
in Part 4. This means all authenticated users can read all contact records but cannot Update, Delete or Create.
Let's update the security to manage which users have access to data. Change the hasReadAccess parameter as follows:
export default function contactRepository(session: SessionUser) {
const contactRepository = new ContactRepository(
{
session: session,
hasReadAccess: hasContactReadAccess,
hasUpdateAccess: async () => false,
hasDeleteAccess: async () => false,
hasCreateAccess: async () => false
},
dynamicsApi
);
return contactRepository;
}
Now we need to implement the hadContactReadAccess
function. Add the following code to the file:
export async function hasContactReadAccess(contact: ContactDto, session?: SessionUser) {
//If no session (ie. no authenticated user), return false
if (!session || !session.user?.id) return false;
//Get the authenticated user record
const authenticatedUser = await externalUserRepository(session).fetchByAuthId(session.user.id);
//Return true if the contact matches the contact of the authenticated user
return !!authenticatedUser && authenticatedUser.contact.id === contact.id;
}
This method first checks if there is an authenticated user (this will be specific to each implementation). It then gets the ExternalUser record, from the dataverse defining the authendicated user. We use a table ExternalUser for this purpose. The library will case this record, so it will be fast. Finally, it checks that the authenticated user contact is the same as the contact to be read. It is it, return true.
Security Policy
The method above creates a lot of flexibility for implementing custom security rule however, it can be difficult to maintain, test and control. The recommended approach to implmenting security is to define a security policy which makes the definition of security more declaritive.
Replace the code above with a reference to a security policy:
export default function contactRepository(session: SessionUser) {
const contactRepository = new ContactRepository(
{
session: session,
securityPolicy: contactSecurityPolicy
},
dynamicsApi
);
return contactRepository;
}
Now define a security policy:
//Define an interface for your custom security set, specifying the type definitions of the User and User Roles your project
export interface MySecurityPolicySet<D extends DataverseDto> extends SecurityPolicySet<D, ExternalUserDto, RoleType> {}
//Define a security policy for contacts
const contactSecurityPolicy: MySecurityPolicySet<ContactDto> = {
read: {
[RoleType.ADMIN]: () => {
//All admin roles get access
return true;
},
default: (contact, user) => {
//Check if user contact id patched contact
return contact.id === user.contact.id;
}
},
update: {
[RoleType.ADMIN]: () => {
//All admin roles get access
return true;
},
default: ( contact: ContactDto, user: ExternalUserDto ) => {
//Check if user contact id patched contact
return contact.id === user.contact.id;
}
},
fetchAuthenticatedUser: async (session: SessionUser) => {
return await externalUserRepository(session).fetchByAuthId(session.user.id);
}
};
The security policy defines the following:
- A fetchAuthenticatedUser which lets you define how to get a reference to your
User
record, in our case from the ExternalUser table. - Defines a set of policy for each operation of
create
,read
,update
,delete
. Only read and update are shown above which meanscreate
anddelete
are not supported for any user. - Implements a function for user role type, and a default function, which lets the system tests if the user has access to the record.
In the above policy, the default
case will always be tested first and only moves on the the role type cases if default
returns false.