Falko Lehmann and Hendrik Kamp have already explained in their blog post on Zero-trust Architecture why zero-trust security models are preferable to traditional perimeter security models in order to minimize damage from cyber attacks. Falko and Hendrik have already mentioned the cloud-agnostic Spiffe framework to verify machine and workload identities. In this blog post, I would like to discuss how the principles of "Never trust, always verify" and "Least Privilege" can be implemented in practice in the Azure Cloud with Azure technologies.
Microsoft Entra ID Applications and Security Principals
With Microsoft Entra ID and Azure AD B2C, Azure provides identity services with an OpenID Connect interface as part of the Microsoft Entra External ID platforms. Today OpenID Connect is an established standard for authenticating users and services on the web.
The "Never trust, always verify" principle requires that every service must verify the identity of the client and its permissions in the service context for every request.
In Microsoft Entra ID, the context in which permissions are assigned and verified is called an application. Each self-operated service requires the creation of a dedicated application. This enables, among other things, the definition of client-specific permissions.
Clients are always Security Principals. These can be defined in the context of an application as a Service Principal or in the context of a user interface as a User Principal. Service principal objects can be uniquely assigned to application objects via the so-called "client ID". The term "App ID" is also used synonymously with the term "Client ID". Both Entra ID objects have separate object IDs, which should not be confused with the client ID or app ID.
Please note that Entra ID Applications can be found in the Azure Portal under the "App Registrations" category. There is also a second category "Enterprise Applications" in the Azure Portal. However, this is somewhat misleadingly named and actually lists service principals. It is possible for service principals to exist without an app registration in their own Azure tenant. For example, if an app registration was created in a third-party tenant and is used by a user in your own tenant or if it is created in connection with a so-called managed identity.
Example service architecture
In order to explain different scenarios in this blog article, it is helpful to first model a minimal service architecture.
We choose a simplified developer platform with Code Repositories and CI Service, similar to GitHub or GitLab, as the domain of this architecture.
Two services are part of this architecture:
- Code Repository Service incl. UI (e.g. Swagger-UI)
- CI service incl. UI (e.g. Swagger UI)
The code repository service is a REST service with the following endpoints:
/repository/
- POST - Create repository resource
- GET - Retrieve all repository resources
/repository/{repository-name}
- GET - Retrieve repository resource
- PUT - Update repository resource
/repository/{repository-name}/code
- GET - Retrieve the current source code of a repository
- PUT - Update the source code of a repository
The CI service in this example is also a REST service with the following endpoints:
/job/
- POST - Create CI job
- Parameters:
repository_name
shell_command
- Parameters:
- GET - Retrieve all CI jobs
- POST - Create CI job
/job/{job-id}
- GET - Retrieve CI job details
The POST /job/
CI Service endpoint is dependent on the Code Repository Service. It is assumed that the CI Service first downloads the source code of the specified repository here and then executes a shell command in the context of the source code directory. This endpoint serves as a central example for the necessary service-to-service communication in the following scenarios.
Scenarios
In the following, I would like to demonstrate various scenarios using the example architecture shown. These scenarios differ primarily in terms of their respective complexity and the consistency with which the "least privilege" principle is implemented. The advanced scenarios are increasingly complex, but also offer ever more extensive protection against privilege escalation attacks, which must always be assumed in zero trust models according to the "Assume Breach" principle.
Perimeter security scenario
In this section, I would like to explain the perimeter security model as an example. I would like to point out at this point that this security model must be regarded as inadequate in most use cases and is incompatible with the Zero Trust security model.
In the perimeter security scenario, the CI service may access all interfaces of the code repository from the internal network. This security model is based on the assumption that no attacker can penetrate the internal network and carry out lateral attacks against internal services.
In Azure, this security model can be mapped using virtual networks. Both services are placed in the same internal network. The authentication and authorization of the CI service against the code repository service is then based exclusively on checking the client IP address.
To fulfill the criterion of the perimeter security model, the client IP must be in the internal network. The client IP to be checked does not have to match the client IP address of the TCP connection. If necessary, the specification of the client IP address from the Forwarded
or X-Forwarded-For
HTTP header is also trusted if the service is located behind a reverse proxy server. In this case, it must be ensured that the code repository service does not have a public IP, that routing to the internal network is only possible from other trusted networks and that the reverse proxy server is configured in such a way that no spoofing of the checked HTTP headers is possible. If the code repository service does not have a public IP and cannot be accessed via an HTTP reverse proxy, perimeter security can also be implicitly assumed without the code repository service explicitly checking the client IP.
This section shows that the perimeter security model is based on a large number of assumptions and that an incorrect configuration of many necessary configurations can lead to the security model being compromised. It is better not to trust any client implicitly, but to check the identities and permissions of each client explicitly.
Finally, it should be mentioned that the perimeter security model should generally be avoided. However, Microsoft also relies on perimeter security to some extent. One example of this is the IMDS endpoint. This is critical when issuing access tokens for managed identities and can generally be accessed within VMs and used without further authentication. This circumstance must be taken into account in particular if applications run in containers within a VM and, in accordance with the "least privilege" principle, should not have access to Managed Identities assigned to the VM. This principle is also not currently fulfilled in Azure Kubernetes Service Nodes and must be updated by the AKS Cluster Administrator.
Service Principal App Role based security model
In this scenario, the CI service is allowed to access all interfaces of the code repository service. The identity and permissions of the client are explicitly checked. Optionally, the permissions can be restricted categorically. Categorical means that access is only permitted to certain types of interfaces.
In this scenario, an app registration must be created for the code repository service.
The CI service must authenticate itself to the code repository service application with the help of a service principal. A service principal in the form of a managed identity is best suited for this. Managed identities can be linked directly to services in Azure so that they can obtain Entra ID tokens for the service principal at runtime without having to use and rotate static secrets.
An alternative to using Managed Identity would be to use the well-documented Client Credentials Flow in conjunction with a dedicated app registration for the CI service. However, this requires a static client secret, which must be regularly renewed at runtime for security reasons. The use of managed identity is therefore preferable, but in some cases impossible.
If the CI service wants to authenticate itself to the code repository service, it must obtain a token that is valid in the context of the code repository service, so that authentication cannot take place with arbitrary Entra ID tokens that are actually intended for a different purpose. Insufficient verification of the intended purpose of access tokens has already led to many security incidents in the past.
When obtaining an OAuth 2 token, the purpose of a token must be specified via a list of scope declarations in the token request. For applications, the scope is always composed of the client ID and the default scope (.default
), for example e620bb52-4cf2-4919-87f1-ac13302b1b60/.default
. Furthermore, the request must be sent to the correct OAuth 2 token issuer, in our case the correct Entra ID directory. The token is then only valid in the context of a specific Entra ID directory (verifiable via the issuer (iss
) token claim) and for a specific application (verifiable via the audience (aud
) token claim).
Example Client Credentials Request:
1curl https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token \ 2 -d grant_type=client_credentials \ 3 -d scope=${CODE_REPOSITORY_SERVICE_CLIENT_ID}/.default \ 4 -d client_id=${CI_SERVICE_CLIENT_ID} \ 5 -d client_secret=${CI_SERVICE_SECRET}
Decoded Token:
1{ 2 "aud": "{CODE_REPOSITORY_CLIENT_ID}", // Audience: Client ID of the code repository server application. Other applications must not accept this token. 3 "iss": "https://login.microsoftonline.com/{TENANT_ID}/v2.0", 4 "azp": "{CI_SERVICE_CLIENT_ID}", // Authorized Party: Client ID of the ci service application. 5 "oid": "{CI_SERVICE_PRINCIPAL_OBJECT_ID}", 6 "roles": [ 7 "Repositories.Code.Read.All" // ci-service has the code-repsitory-service app role "Repositories.Code.Read.All" assigned. Therefore it is allowed to read the repository code of any user. 8 ], 9 "sub": "{CI_SERVICE_PRINCIPAL_OBJECT_ID}", 10 "tid": "{TENANT_ID}", 11 [...] 12}
It is also possible to assign so-called app roles to a service principal. If this is the case, these roles are listed in the roles
claim. In our scenario, we can use app roles to give the CI service categorical access to the GET /repository/{repository-name}/code
endpoints, for example in the form of a "Repository.Code.Read.All" app role. A more granular permission structure for specific repositories is not possible with App Roles.
App Roles can be assigned to App Registrations in the Azure Portal via the "API Permissions" tab. For Managed Identities or other Service Principals without App Registration, this assignment is unfortunately only possible via the REST API or e.g. via Terraform.
The possibility of assigning extensive permissions in the form of app roles to service principals should be handled very carefully. The Midnight Blizzard attack on Microsoft in January 2024 also had such a big impact, because the attacker was able to assign the "full_access_as_app" app role for a Microsoft Exchange Server to its own application. "full_access_as_app" gives the privileged service principal full access to all emails of all users. The very existence of corresponding app roles is therefore already a risk.
Use of delegated user permissions by service principals
In this scenario, the CI service can use a user's permissions to access all repositories to which the user would normally have access in the code repository application. Permissions can also be further restricted categorically here. Categorical means that only access to certain interfaces is permitted.
In this scenario, an app registration must also be created for the code repository service so that security principals can authenticate themselves against it. The CI service also requires an app registration in order to be provided with a service principal and to be able to configure delegated user permissions.
In our example, we want to give the CI service the ability to access the code of the repositories to which a user also has access. The permissions should also only be granted during the processing of a user request. Outside the processing of a corresponding request, the CI service should not have any permissions to access the code repository service in accordance with the "least privileges" principle. The ability of a service principal to take over the permissions of a user is also known as user impersonation.
The definition of delegated user permissions is mapped in App Registrations via scope definitions. Scope names share a namespace with App Roles. If there is already an app role "Repository.Code.Read.All", we cannot create a scope with the same name. For our example scenario, it makes sense to define at least two scopes: "UserImpersonation.ReadWrite.All" and "UserImpersonation.Repository.Code.Read.All". The first scope can be used by a UI of the code repository service to manage all data of the service. The second scope can be used by the CI service to be able to use the user's access permissions to the code of all the user's repositories.
The assignment of scopes to an app registration is also possible via the "API Permissions" tab in the Azure portal, similar to the assignment of app roles.
After the "UserImpersonation.Repository.Code.Read.All" scope has been assigned to the CI service, an additional consent dialog is displayed the next time the user logs in. In this dialog, the user must confirm that the CI service may impersonate the identity of the user when communicating with the code repository service and may read the code of all repositories with the user's permissions. Without further scope, the CI service may not interact with any other code repository service resources of the user. A Microsoft Entra administrator can also grant the user's consent across the board for all users of the Entra directory. This consent is then no longer explicitly required from each user when logging in.
User consent dialog for Code Repository Service
User consent dialogs for the use of the CI Service with the consent to use the Code Repository Service with the user's permissions by the CI Service !
In order to communicate with the code repository service, the CI service must exchange a user token that is addressed to its own service for a token that is issued specifically for the code repository service. This token exchange can be realized via the On-Behalf-Of Flow.
Example On-Behalf-Of Request
1curl https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token \ 2 -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \ 3 -d requested_token_use=on_behalf_of \ 4 -d scope=${CODE_REPOSITORY_SERVICE_CLIENT_ID}/.default \ 5 -d client_id=${CI_SERVICE_CLIENT_ID} \ 6 -d client_secret=${CI_SERVICE_SECRET} \ 7 -d assertion=${USER_TOKEN_WITH_CI_SERVICE_AUDIENCE}
Decoded Token
1{ 2 "aud": "{CODE_REPOSITORY_CLIENT_ID}", // Audience: Client ID of the code repository server application. Other applications must not accept this token. 3 "iss": "https://login.microsoftonline.com/{TENANT_ID}/v2.0", 4 "azp": "{CI_SERVICE_CLIENT_ID}", // Authorized Party: Client ID of the ci service application. 5 "name": "{USER_DISPLAY_NAME}", 6 "oid": "{USER_PRINCIPAL_OBJECT_ID_IN_ENTRA_ID}", 7 "preferred_username": "{PREFERRED_USERNAME}", 8 "scp": "UserImpersonation.Repositories.Code.Read.All", // Scopes: Token authorizes to read repository code on behalf of the user. This scope is only valid for the specfic audience (in this case the code repository service)! 9 "sub": "{APPLICATION_SCOPED_USER_ID_STRING}", 10 "tid": "{TENANT_ID}", 11 [...] 12}
Unfortunately, there is currently no way to perform this token exchange without a static client secret, which has to be rotated regularly. According to the documentation, the flow does support a JWT "client_assertion" parameter as an alternative to the secret. However, when trying to use this for the exchange of a user token, the request fails.
Fine-grained delegated user permissions
For the CI service scenario, it is conceivable that an even more fine-grained permission structure would be desirable. For example, it would be nice if a user could only allow the CI service to load the code of certain repositories, similar to what is possible on GitHub with fine-grained Personal Access Tokens. In the context of a CI service, this can limit the impact of a compressed repository to a single repository and restrict further lateral movement by an attacker.
Unfortunately, Azure does not provide a solution for this type of permission structure. Correspondingly advanced requirements currently have to be solved individually by each application or organization. The Azure Resource Manager also has correspondingly advanced requirements and maps these via Azure RBAC. The Azure RBAC system can therefore serve as a guide under certain circumstances. Here, fine-grained permissions can be linked to roles, and these in turn can be linked hierarchically to users at resource and sub-resource level. Azure RBAC can be used to a limited extent by Azure customers for their own services if these are operated in Azure as a custom resource provider. If this is the case, Azure RBAC executes the permissions check for access to resources of the corresponding custom resource provider.
Regardless of Azure-specific solutions, it may be worth taking a look at OpenFGA and Spicedb, as well as the new OAuth 2.0 Rich Authorization Requests Standard. Unfortunately, the latter has only been implemented in very few products to date. Keywords for the categories of these solutions are ABAC (Attribute-Based Access Control) and ReBAC (Relationship-based access control ). Examples of GitHub-like permission models are available for OpenFGA and Spicedb [1] [2].
Conclusion
The Zero Trust security model places far-reaching requirements on the identity and access architecture of service architectures. As shown in the scenarios relating to security principal authentication with app role authorization and authorization with delegated user permissions, Microsoft Entra ID already provides developers with some tools for this. Fine-grained permissions structures at resource and sub-resource level, on the other hand, are limited to the implementation of custom resource providers and may require an Azure-independent solution.
If you would like to understand the Entra ID-specific scenarios in more detail, you are welcome to take a look at the Microsoft Entra ID Playground GitHub repository, which was developed as part of the creation of this blog post. This contains Terraform modules for setting up a sample environment in Azure and the sample implementations of the CI and Code Repository Services in ASP.NET Core.
More articles
fromPhilip Sanetra
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog author
Philip Sanetra
IT Consultant & Software Engineer
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.