1. Introduction
The ecr-acl extension adds access control lists (acls) to the arveo content repository service. An acl lists users and groups together with the access right each of them is granted. Entities reference at most one acl. Whenever an entity is accessed, the content repository evaluates the acl of the entity for the current user using its attribute based access control (abac).
The extension consists of three parts:
-
Type definitions (
ecr-acl-type-definitions): the typesaclandacl_entry, the enumerationPrincipalType, the mixin interfaceAclProtectedfor protected types and the abac function evaluating acls. This jar is deployed into the content repository service. -
Events (
ecr-acl-events): an event listener restricting the creation of acls to acl administrators. This jar is deployed into the content repository service as well. -
ecr-acl-service: a REST service to manage acls and to query the access rights of the current user on an entity, together with generated clients (Java, TypeScript, C#).
2. Concepts
2.1. Acls and acl entries
An acl (type acl) has a unique name and an optional description. The grants are stored as acl entries
(type acl_entry) referencing the acl:
| Attribute | Type | Description |
|---|---|---|
|
|
The id of the acl the entry belongs to. Deleting an acl deletes its entries. |
|
|
|
|
|
The user id for users, the group id provided by the identity provider for groups. |
|
|
The numeric value of the granted access right, see below. |
There is at most one entry per acl and principal. Invalid principal types and access rights are rejected.
The principal type is the enumeration de.eitco.ecr.acl.types.PrincipalType. It is registered as enumeration type,
so the database stores the column using the custom type acl_principal_type and the generated TypeScript and C#
clients contain a corresponding enumeration.
The access rights are those of de.eitco.ecr.common.AccessRight. They are hierarchical: an entry granting a right
also grants every weaker right. An entry stores the right as its numeric value (AccessRight.getValue()), which
reflects this hierarchy: a right includes every right with a lower value. Only the values listed below are valid.
In Java, use AccessRight.getValue() to set an entry and AccessRight.fromValue(short) to read it.
| Access right | Value | Allows |
|---|---|---|
|
4000 |
finding the entity and reading its metadata |
|
6000 |
reading the entity |
|
8000 |
reading metadata, content and annotations |
|
12000 |
adding annotations and comments |
|
16000 |
changing metadata and content |
|
20000 |
overwriting the current version |
|
24000 |
deleting the entity |
|
32766 |
changing the acl of the entity |
2.2. Protected entities
Types whose entities shall be protected extend the interface AclProtected. This adds the optional attribute acl_id
referencing an acl and the abac function evaluating it. Hence an entity has at most one acl.
-
Entities without an acl are accessible for everyone.
-
Entities with an acl are accessible for a user if the acl contains an entry granting the required right (or a stronger one) to the user or to one of the groups of the user.
-
An acl that is still referenced by an entity cannot be deleted.
2.3. Evaluation
Acls are evaluated by the content repository service for the user calling it:
-
The user id is the unique identifier of the user provided by the identity provider (for Keycloak the
subclaim of the token). -
The groups of the user are read from a configurable user attribute (see Configuration). The attributes contain the claims of the token, so the identity provider has to put the group ids into a token claim, e.g. using the Keycloak group membership mapper (claim
group-memberships, group ids are the full group paths like/acl-group-a).
The content repository adds the acl condition to every query. Thus entities a user is not allowed to browse are
neither found by searches nor returned by reads (404). Operations requiring a stronger right fail with an
InsufficientAccessRightException (400) if the entity is visible but the right is not granted.
| Acl administrators are no super users. They can only access entities whose acl grants them access. |
2.4. Administration of acls
Every authenticated user may read acls and acl entries. Creating, changing and deleting them requires the configured
administration authority (default ACL_ADMIN).
2.5. Changing the acl of an entity
The attribute acl_id is a security property. Changing it requires the right CHANGE_ACCESS_RIGHTS on the entity.
The content repository requires CHANGE_ACCESS_RIGHTS as soon as acl_id is part of an update, even if the
value is unchanged. Updates that shall keep the acl must omit acl_id. The generated update operations omit
attributes without a value, so it is sufficient to leave acl_id empty.
|
2.6. Access rights of the current user
Frontends usually need to know which actions to offer for an entity. Two endpoints return the access rights the
current user holds on an entity. Both derive the type definition from the id, so documents, containers, folders and
relations are supported. Meta entities have no typed id and are rejected (400). Entities that do not exist or are
not visible for the current user result in 404.
GET api/access-rights/{id} returns the rights granted by the acl of the entity only:
-
The entity is loaded to determine its
acl_id, then the entries of the acl matching the current user or one of the groups of the user are loaded. The strongest matching entry determines the rights, since access rights are hierarchical. -
Other
@Securityrules of the type and the access rights on the type definition are not considered. -
Entities without an acl have no rights granted by an acl, so the result contains no rights and no
aclId. -
The groups are read from the token of the user using the property
ecr-acl.acl.group-attribute. It has to match the configuration of the content repository service.
GET api/access-rights/{id}/evaluate returns the exact rights, which is considerably more expensive:
-
A right is contained if both the abac function of the type (i.e. the acl or any other
@Securityrule) and the access rights on the type definition grant it. -
Every right is evaluated separately by the content repository, so non-hierarchical
@Securityrules are reported correctly as well.
See Querying the access rights of the current user for when to use which variant and how to interpret a 404 compared to an empty list
of rights.
2.7. Trying out an acl for a user
GET api/access-rights/acl/{aclId}/user/{userId}?group={groupId}&group=… lets acl administrators check which access
right an acl assigns to a specific user:
-
The entry of the user and the entries of the groups passed by the optional query parameter
group(repeatable) are considered. The groups of another user are not known to the service, so they have to be passed by the caller in the same form the identity provider puts them into the token, e.g./acl-group-a. Without groups only the entry of the user itself is considered. -
The strongest matching entry determines the result, since access rights are hierarchical.
-
The result contains the
aclId, theuserId, the consideredgroupsand the name of the access right asright.rightisnullif the acl contains no entry for the user or one of the groups. -
The operation requires the configured administration authority (
403otherwise). Unknown acls result in404.
3. Configuration
3.1. Content repository service
The acls are evaluated in the content repository service, so the following properties belong to its configuration:
ecr-acl:
acl:
# user attribute (token claim) containing the group ids of the user
group-attribute: group-memberships
# authority required to create, change or delete acls
admin-authority: ACL_ADMIN
Both properties are optional, the values shown are the defaults.
3.2. ecr-acl-service
The ecr-acl-service calls the content repository on behalf of the current user. Otherwise the content repository would evaluate the acls for the technical user of the service. The user is impersonated using an OAuth2 token exchange:
commons:
security:
oauth2:
client:
auto-impersonate: true
auto-impersonate is enabled by default. The token exchange requires an additional client registration:
# used for auto-impersonation: the service exchanges the token of the user to call the content repository
# service on behalf of the user, so that acls are evaluated for this user
keycloak-token-exchange:
provider: keycloak
client-id: "obo-client"
client-secret: "PoK5dW33w9TdBt5lUGxhgnsaIoFtj0zF"
authorization-grant-type: "urn:ietf:params:oauth:grant-type:token-exchange"
In Keycloak the client used for the token exchange (here obo-client) needs
-
standard token exchange enabled (client attribute
standard.token.exchange.enabled), -
the mappers for the claims the content repository needs: the authorities, the audience
content-repository-serviceand the group claim, -
to be contained in the audience of the user tokens sent to the ecr-acl-service (audience mapper on the client used by the frontend).
See the client obo-client in the Keycloak realm of the system test (test/system-test/src/test/resources/keycloak)
for an example.
4. Performance
This chapter is meant for administrators analyzing the performance of a system using acls. It describes the queries caused by the acl evaluation and the database indexes supporting them.
4.1. The acl condition
The content repository adds the acl condition to every query on a protected type, i.e. to searches, reads, updates
and deletes. For a table <table> of a type extending AclProtected the condition is:
<table>.acl_id IS NULL
OR <table>.acl_id IN (
SELECT acl FROM acl_entry
WHERE access_right >= <the value of the requested right>
AND ((principal_type = 'USER' AND principal_id = <user id>)
OR (principal_type = 'GROUP' AND principal_id IN (<group ids of the user>))))
Since access rights are hierarchical and stored by value, a single comparison finds the entries granting the
requested right or a stronger one. The group condition is omitted for users without groups. The performance of the
condition therefore depends on two lookups: finding the acls granting access to the current user in acl_entry, and
finding the protected entities referencing these acls (or no acl at all) in <table>.
4.2. Indexes
The type definitions declare the following indexes. The content repository creates them when it updates the database schema on startup.
| Index | Columns | Purpose |
|---|---|---|
|
|
Finds the acls granting a right to a user or a group. The principal columns are compared for equality and come first, the access right is compared by range and follows them. Hence the entries of a principal granting at least the requested right are adjacent in the index and found by a single range scan. The index contains every column of the subquery, so the database can answer the subquery from the index alone without reading the table (index only scan). The database can look up users and groups separately and combine the results (bitmap or). |
|
|
Finds the entities referencing one of the acls found by the subquery, and the entities without an acl
( |
unique constraint |
|
Ensures one entry per acl and principal. It also serves loading the entries of an acl and deleting them together with the acl. It does not help the acl condition, since its first column is the acl. |
Creating the index on acl_id for an existing type with many entities takes a while and may block writes to
the table during the schema update. Plan the first start of a content repository with this version accordingly.
|
4.3. Analyzing slow queries
To check whether the indexes are present, list the indexes of the tables in PostgreSQL:
SELECT tablename, indexname, indexdef FROM pg_indexes
WHERE tablename = 'acl_entry' OR indexdef LIKE '%(acl_id)%';
To check whether a query uses them, take the query from the log of the content repository or from
pg_stat_statements and run it with EXPLAIN (ANALYZE, BUFFERS). The expected plan contains
-
an
Index Only Scan(or aBitmap Index Scan) onacl_entry_access_checkfor the subquery, whoseIndex Condcontains the principal and the access right, and -
an
Index ScanorBitmap Index Scanon<table>_acl_id_cifor the protected table, provided the query is not restricted more selectively by other conditions.
A Seq Scan on acl_entry or on a large protected table indicates a missing index or outdated statistics. Run
ANALYZE acl_entry; and ANALYZE <table>; after importing large amounts of data, then check the plan again.
A sequential scan of a protected table is correct if most of its entities are visible for the user anyway, e.g. because most of them have no acl or reference an acl granting access to everyone. Reading the table is cheaper than using the index in this case.
4.4. Influencing factors
- Number of groups per user
-
The group ids of the user are part of every query. Hundreds of groups per user enlarge every query and the work of the subquery. Only put the groups relevant for acls into the group claim of the token (see Configuration).
- Number of acls
-
The subquery returns every acl granting access to the user. Many small acls, e.g. one acl per entity, result in large subquery results. Prefer a moderate number of acls shared by many entities, e.g. one acl per department or project.
- Access rights of the current user
-
The endpoint
api/access-rights/{id}executes two queries, whileapi/access-rights/{id}/evaluateexecutes up to eight (see Querying the access rights of the current user). Avoid callingevaluatefor long lists of entities. - Acl administrators
-
Acl administrators are no super users, the acl condition is applied to their queries as well. Their queries are as expensive as those of any other user.
5. REST API
| Path | Methods | Description |
|---|---|---|
|
CRUD, search |
Acls. |
|
CRUD, search |
Acl entries. |
|
CRUD, search |
An acl together with its entries in a single operation. |
|
|
The access rights granted to the current user by the acl of an entity. |
|
|
The exact access rights of the current user on an entity. |
|
|
The access right an acl assigns to a user (acl administrators only). |
The OpenAPI description is available at /openapi.json of the service, the Swagger UI at /swagger.
6. Developer guide
6.1. Protecting a type
To protect the entities of a type by acls, let its type definition extend AclProtected. The examples in this
guide use the following container type:
@Type(ObjectType.CONTAINER)
public interface Record extends AclProtected {
@SystemProperty(SystemPropertyName.ID)
long getEcrId();
@Mandatory
@Unique
String getExternalId();
void setExternalId(String externalId);
}
AclProtected contributes the attribute and the abac function:
public interface AclProtected {
/**
* The id of the acl protecting this entity or {@code null} if the entity is not protected. The index serves the acl
* check ({@code acl_id is null or acl_id in (...)}) and the foreign key check when an acl is deleted. It is left
* unnamed, so every protected type gets its own index name derived from its table.
*/
@Optional
@Indexed
@SecurityProperty
@ForeignKey(target = Acl.class, targetProperty = "id")
Long getAclId();
void setAclId(Long aclId);
@Security
static Expression<Boolean> checkAcl(Alias alias, AuthenticationContext authenticationContext, AccessRight right) {
return AclConditions.accessCondition(alias, authenticationContext, right);
}
}
The required maven dependency is:
<dependency>
<groupId>de.eitco.ecr.acl</groupId>
<artifactId>ecr-acl-type-definitions</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
The types Acl and AclEntry are registered by ecr-acl-type-definitions itself. Register your own types as
usual:
@AutoConfiguration
@Register(Record.class)
public class AclTestTypeRegistration implements TypeDefinitionRegistration {
}
Deploy ecr-acl-type-definitions, ecr-acl-events and the jar containing your types into the content repository
service (directory /app/extensions of the arveo image).
Types that need additional rules can define their own @Security method instead of inheriting the one of
AclProtected. Use AclConditions.accessCondition to combine the acl with further conditions:
@Security
static Expression<Boolean> checkAccess(Alias alias, AuthenticationContext authenticationContext, AccessRight right) {
Expression<Boolean> aclCondition = AclConditions.accessCondition(alias, authenticationContext, right);
Expression<Boolean> notArchived = EcrQueryLanguage.condition().alias(alias).field("archived").equalTo().value(false).holds();
return right.getValue() > AccessRight.READ.getValue() ? Eql.and(aclCondition, notArchived) : aclCondition;
}
6.2. Using the Java SDK
6.2.1. Dependency and configuration
Acls are managed using the SDK of the ecr-acl-service. The protected entities themselves are accessed using the SDK of the content repository as usual, the acls are evaluated there for the calling user.
The Spring based SDK of the ecr-acl-service connects to the service using a Spring RestTemplate:
<dependency>
<groupId>de.eitco.ecr.acl</groupId>
<artifactId>ecr-acl-sdk-http-spring</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
The service is located by Spring Cloud discovery using the service id ecr-acl-service. The SDK authenticates using
the OAuth2 client registrations of the application, e.g.:
spring:
security:
oauth2:
client:
registration:
keycloak-password:
provider: keycloak
client-id: "my-client"
client-secret: "..."
authorization-grant-type: "password"
scope: openid
provider:
keycloak:
issuer-uri: "https://keycloak.example.com/realms/my-realm"
cloud:
discovery:
client:
simple:
instances:
ecr-acl-service:
- uri: http://localhost:31420
For every resource the SDK provides a client factory bean:
@Autowired
private AclResourceClientFactory aclClientFactory;
@Autowired
private AclEntryResourceClientFactory aclEntryClientFactory;
@Autowired
private AclEntryWithAclResourceClientFactory aclWithEntriesClientFactory;
@Autowired
private AccessRightsResourceClientFactory accessRightsClientFactory;
The protected entities are accessed using the content repository SDK (de.eitco.ecr:ecr-sdk-http, service id
content-repository-service):
@Autowired
private TypeDefinitionServiceClient typeDefinitionServiceClient;
6.2.2. Creating an acl
Create the acl first, then add one entry per user or group:
AclResourceClient aclClient = aclClientFactory.newClient();
AclEntryResourceClient aclEntryClient = aclEntryClientFactory.newClient();
Acl newAcl = new AclImplementation();
newAcl.setName("project-" + UUID.randomUUID());
newAcl.setDescription("Members of group a may read, user b may write");
Acl acl = aclClient.create(newAcl);
// a group, identified by the group id provided by the identity provider
AclEntry groupEntry = new AclEntryImplementation();
groupEntry.setAcl(acl.getEcrId());
groupEntry.setPrincipalType(PrincipalType.GROUP);
groupEntry.setPrincipalId("/acl-group-a");
groupEntry.setAccessRight(AccessRight.READ.getValue());
aclEntryClient.create(groupEntry);
// a single user, identified by the user id
AclEntry userEntry = new AclEntryImplementation();
userEntry.setAcl(acl.getEcrId());
userEntry.setPrincipalType(PrincipalType.USER);
userEntry.setPrincipalId(USER_B_ID);
userEntry.setAccessRight(AccessRight.WRITE.getValue());
aclEntryClient.create(userEntry);
Alternatively the acl and all of its entries can be created in a single request using the resource
api/acl-with-entries. The acl is passed as properties of an AclEntryWithAclBatchInput, the entries as its
elements. The service creates them in one batch operation of the content repository, so either all of them are
created or none:
AclEntryWithAclResourceClient aclWithEntriesClient = aclWithEntriesClientFactory.newClient();
Acl newAcl = new AclImplementation();
newAcl.setName("project-" + UUID.randomUUID());
newAcl.setDescription("Members of group a may read, user b may write");
// the attribute acl of the entries is set by the service
AclEntry groupEntry = new AclEntryImplementation();
groupEntry.setPrincipalType(PrincipalType.GROUP);
groupEntry.setPrincipalId("/acl-group-a");
groupEntry.setAccessRight(AccessRight.READ.getValue());
AclEntry userEntry = new AclEntryImplementation();
userEntry.setPrincipalType(PrincipalType.USER);
userEntry.setPrincipalId(USER_B_ID);
userEntry.setAccessRight(AccessRight.WRITE.getValue());
AclEntryWithAclBatchInput input = new AclEntryWithAclBatchInput();
input.setProperties(newAcl);
input.setElements(List.of(groupEntry, userEntry));
// the acl and all of its entries are created in a single batch operation
AclEntryWithAclBatchInput created = aclWithEntriesClient.create(input);
Acl acl = created.getProperties();
List<AclEntry> entries = created.getElements();
The entries of an acl can be searched like any other entity:
SearchService<AclEntry> entries = aclEntryClient::search;
List<AclEntry> entriesOfAcl = entries.where()
.contextReference("acl").equalTo().value(acl.getEcrId())
.holds().unpaged();
6.2.3. Protecting an entity
To protect an entity, set the id of the acl when creating it in the content repository:
TypedContainerServiceClient<Record> records = typeDefinitionServiceClient.getContainerServiceClient()
.byClass(Record.class);
Record record = records.createTypeInstance();
record.setExternalId("record-" + UUID.randomUUID());
record.setAclId(acl.getEcrId());
ContainerId recordId = records.createEntity(record).getIdentifier();
| If the creating user is not granted access by the acl, the content repository returns the created entity without attributes. Use its identifier in this case. |
6.2.4. Updating a protected entity
Updates that keep the acl must not contain acl_id. Only the attributes set on a new type instance are part of an
update:
// only attributes set on a new type instance are updated, acl_id is kept and WRITE is sufficient
Record update = records.createTypeInstance();
update.setExternalId(externalId + "-changed");
Record updated = records.newClient(recordId).updateAttributes(update).getEntity();
Setting acl_id changes the acl and requires CHANGE_ACCESS_RIGHTS:
// changing the acl requires CHANGE_ACCESS_RIGHTS on the entity
Record aclChange = records.createTypeInstance();
aclChange.setAclId(otherAcl.getEcrId());
Record changed = records.newClient(recordId).updateAttributes(aclChange).getEntity();
6.2.5. Querying the access rights of the current user
The resource api/access-rights offers two variants to determine the rights of the current user on an entity. Both
take the id of a document, container, folder or relation and return an EntityAccessRights with the id, the type
name, the aclId of the entity and the granted rights. The rights are returned as names of AccessRight
constants, ordered from the weakest to the strongest right.
Rights granted by the acl: get
get determines the rights granted to the current user by the acl assigned to the entity:
AccessRightsResourceClient accessRightsClient = accessRightsClientFactory.newClient();
EntityAccessRights accessRights = accessRightsClient.get(recordId);
boolean mayEdit = accessRights.getRights().contains(AccessRight.WRITE.name());
The service executes two queries on the content repository:
-
It searches the entity by its id on behalf of the current user. This checks the visibility and reads the attribute
acl_id. -
It searches the entries of this acl matching the user id or one of the groups of the user. Since access rights are hierarchical, the strongest matching entry determines the result: an entry granting
WRITEresults inBROWSE,READ_ENTITY,READ,COMMENTandWRITE.
Everything except the acl is ignored: neither further @Security rules of the type nor the access rights on the type
definition are considered. The groups are read from the token of the user using the property
ecr-acl.acl.group-attribute, which has to match the configuration of the content repository service.
Use get if the entity is protected by an acl and the acl alone decides about the access, which is the case for all
types simply extending AclProtected. It is cheap enough to be called for every entity a frontend displays.
Exact rights: evaluate
evaluate determines the rights exactly as the content repository checks them:
AccessRightsResourceClient accessRightsClient = accessRightsClientFactory.newClient();
// evaluates every right separately, considering all @Security rules and the rights on the type definition
EntityAccessRights accessRights = accessRightsClient.evaluate(recordId);
After the visibility check, the service executes one query per AccessRight except BROWSE (granted by the
visibility itself), letting the content repository evaluate
the abac function of the type for this right. A right is contained if both the abac function (the acl and every other
@Security rule) and the access rights on the type definition grant it. Rights are evaluated separately, so
non-hierarchical @Security rules are reported correctly as well, e.g. a rule denying WRITE for archived entities
while the acl grants it.
Use evaluate if a type defines its own @Security rules, if the access rights on type definitions are restricted,
or for entities that are not protected by an acl. Since it executes up to eight queries, avoid calling it for long
lists of entities.
get |
evaluate |
|
|---|---|---|
Path |
|
|
Queries on the content repository |
2 |
up to 8 (1 + one per access right except |
Acl |
yes |
yes |
Other |
no |
yes |
Access rights on the type definition |
no |
yes |
Entity without acl |
no rights |
the rights granted by the remaining rules, usually all |
Not found versus an empty list of rights
Both variants check the visibility of the entity the same way, using the complete access control of the content repository. The result therefore distinguishes two different situations:
404(ResourceNotFoundException)-
The entity does not exist or the current user may not browse it. The service deliberately does not tell these cases apart, otherwise users could detect the existence of entities they are not allowed to see. A frontend should handle the entity as if it did not exist, e.g. remove it from a list. This applies to both variants.
- An empty list of rights
-
The entity exists and is visible for the current user, but no right is granted by the evaluated source. In practice this occurs for
getonly: an entity found byevaluateis visible, so the result contains at leastBROWSEunless the access rights on the type definition deny reading. Forgetan empty list does not mean that the user has no access, only that no acl grants a right:-
aclIdisnull: the entity is not protected by an acl, e.g. becauseacl_idis not set or the type does not extendAclProtected. ForAclProtectedtypes this means everyone may access the entity, provided the access rights on the type definition allow it. Useevaluateto determine the actual rights. -
aclIdis set: the acl contains no entry for the user or the groups of the user. The entity is nevertheless visible, so another@Securityrule of the type grants the access. Useevaluateto determine the actual rights.
-
The following example combines both variants. It uses the cheap get and falls back to evaluate for entities
without an acl:
private static Optional<List<String>> rightsToOffer(AccessRightsResourceClient accessRightsClient, long entityId) {
try {
EntityAccessRights accessRights = accessRightsClient.get(entityId);
if (accessRights.getAclId() == null) {
// the entity is visible but not protected by an acl, so no acl grants any right: evaluate the exact rights
return Optional.of(accessRightsClient.evaluate(entityId).getRights());
}
// the rights the acl grants to the current user
return Optional.of(accessRights.getRights());
} catch (ResourceNotFoundException e) {
// the entity does not exist or the current user may not even browse it, so there is nothing to offer
return Optional.empty();
}
}
6.2.6. Errors
| Exception | HTTP status | Cause |
|---|---|---|
|
404 |
The entity does not exist or the current user may not browse it. |
|
400 |
The entity is visible, but the required right is not granted, e.g.
|
|
400 |
Duplicate acl name or duplicate entry for a principal. |
|
400 |
Invalid principal type or access right. |
|
400 |
The access rights of a meta entity were requested. |
6.3. Using the TypeScript SDK
The TypeScript SDK of the ecr-acl-service is published as npm package @eitco/ecr-acl-service:
"dependencies": {
"@eitco/ecr-acl-service": "1.0.0-SNAPSHOT-build.10"
}
For applications based on ecc an additional data provider built on top of the SDK is available as npm package
@eitco/ecr-acl-data-provider:
"dependencies": {
"@eitco/ecr-acl-data-provider": "1.0.0-SNAPSHOT-build.10"
}
6.4. Using the C# SDK
The C# SDK of the ecr-acl-service is published as nuget package Eitco.Eitco-Content-Repository.EcrAcl (target
framework net8.0):
<ItemGroup>
<PackageReference Include="Eitco.Eitco-Content-Repository.EcrAcl" Version="1.0.0-SNAPSHOT-build.10" />
</ItemGroup>
7. Release Notes
7.1. Release notes 1.0.0-SNAPSHOT (October 12, 2026)
Details
New Features
Changes
Bug Fixes
Interface breaking changes
Configuration changes
Manual migration steps