Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for keycloak and option to disable group-of-groups #37

Merged
merged 16 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
08209bd
Add support for keycloak and option to disable group-of-groups
BlackVoid May 4, 2024
2219552
:rotating_light: Run linting fixes
jemrobinson May 23, 2024
90bcb0f
:rotating_light: Fix linting errors in README
jemrobinson May 21, 2024
6a056aa
:memo: Add explanation of how/why to use the groups-of-groups feature
jemrobinson May 23, 2024
a312892
:truck: Rename disable-groups-of-groups to disable-mirrored-groups
jemrobinson May 23, 2024
56464c6
:wrench: Add additional optional attributes to LDAPInetOrgPerson
jemrobinson May 23, 2024
278c72d
:bug: Revert change to OAuthClient.query which stopped passing client…
jemrobinson May 23, 2024
9cc4973
:memo: Add debug messages for each group and user added to the LDAP tree
jemrobinson May 21, 2024
819e360
:loud_sound: Add additional debug messages for user and group membership
jemrobinson May 21, 2024
83a0ff3
:bug: Continue processing groups even if attributes cannot be process…
jemrobinson May 21, 2024
a2ec61b
:bug: Ensure that userPrincipalName key exists before using it to con…
jemrobinson May 21, 2024
b5fa15f
:rotating_light: Fix linting error in README
jemrobinson May 23, 2024
38a28e4
:rotating_light: Additional linting fixes
jemrobinson May 23, 2024
f986e41
:recycle: Switch KeycloakClient to use UidCache for generating missin…
jemrobinson May 23, 2024
2483fa6
Fix fetching of uid from multi value attribute fix naming issue for m…
BlackVoid May 25, 2024
8cca5d6
Merge branch 'main' into feature/keycloak
jemrobinson May 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Start the `Apricot` server on port 1389 by running:
python run.py --client-id "<your client ID>" --client-secret "<your client secret>" --backend "<your backend>" --port 1389 --domain "<your domain name>" --redis-host "<your Redis server>"
```

Alternatively, you can run in Docker by editing `docker/docker-compose.yaml` and running:
If you prefer to use Docker, you can edit `docker/docker-compose.yaml` and run:

```bash
docker compose up
Expand Down Expand Up @@ -67,7 +67,9 @@ member: <DN for each user belonging to this group>

## Primary groups

Note that each user will have an associated group to act as its POSIX user primary group
:exclamation: You can disable the creation of mirrored groups with the `--disable-primary-groups` command line option :exclamation:

Apricot creates an associated group for each user, which acts as its POSIX user primary group.

For example:

Expand Down Expand Up @@ -97,6 +99,8 @@ member: CN=sherlock.holmes,OU=users,DC=<your domain>

## Mirrored groups

:exclamation: You can disable the creation of mirrored groups with the `--disable-mirrored-groups` command line option :exclamation:

Each group of users will have an associated group-of-groups where each user in the group will have its user primary group in the group-of-groups.
Note that these groups-of-groups are **not** `posixGroup`s as POSIX does not allow nested groups.

Expand All @@ -109,6 +113,7 @@ objectClass: posixGroup
objectClass: top
...
member: CN=sherlock.holmes,OU=users,DC=<your domain>
...
```

will have an associated group-of-groups
Expand All @@ -122,6 +127,32 @@ member: CN=sherlock.holmes,OU=groups,DC=<your domain>
...
```

This allows a user to make a request for "all primary user groups needed by members of group X" without getting a large number of primary user groups for unrelated users. To do this, you will need an LDAP request that looks like:

```ldif
(&(objectClass=posixGroup)(|(CN=Detectives)(memberOf=Primary user groups for Detectives)))
```

which will return:

```ldif
dn:CN=Detectives,OU=groups,DC=<your domain>
objectClass: groupOfNames
objectClass: posixGroup
objectClass: top
...
member: CN=sherlock.holmes,OU=users,DC=<your domain>
...

dn: CN=sherlock.holmes,OU=groups,DC=<your domain>
objectClass: groupOfNames
objectClass: posixGroup
objectClass: top
...
member: CN=sherlock.holmes,OU=users,DC=<your domain>
...
```

## OpenID Connect

Instructions for specific OpenID Connect backends below.
Expand All @@ -146,8 +177,34 @@ Do this as follows:
- Set the expiry time to whatever is relevant for your use-case
- You **must** record the value of this secret at **creation time**, as it will not be visible later.
- Under `API permissions`:
- Ensure that the following permissions are enabled
- Enable the following permissions:
- `Microsoft Graph` > `User.Read.All` (application)
- `Microsoft Graph` > `GroupMember.Read.All` (application)
- `Microsoft Graph` > `User.Read.All` (delegated)
- Select this and click the `Grant admin consent` button (otherwise manual consent is needed from each user)
- Select this and click the `Grant admin consent` button (otherwise each user will need to manually consent)

### Keycloak

You will need to use the following command line arguments:

```bash
--backend Keycloak --keycloak-base-url "<your hostname>/<path to keycloak>" --keycloak-realm "<your realm>"
```

You will need to register an application to interact with `Keycloak`.
Do this as follows:

- Create a new `Client` in your `Keycloak` instance.
- Set the name to whatever you choose (e.g. `apricot`)
- Enable `Client authentication`
- Enable the following authentication flows and disable the rest:
- Direct access grants
- Service account roles
- Under `Credentials` copy `client secret`
- Under `Service account roles`:
- Click on `Assign role` then `Filter by clients`
- Assign the following roles:
- `realm-management` > `view-users`
- `realm-management` > `manage-users`
- `realm-management` > `query-groups`
- `realm-management` > `query-users`
14 changes: 11 additions & 3 deletions apricot/apricot_server.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import inspect
import sys
from typing import Any, cast

Expand All @@ -21,6 +22,7 @@ def __init__(
port: int,
*,
debug: bool = False,
enable_mirrored_groups: bool = True,
redis_host: str | None = None,
redis_port: int | None = None,
**kwargs: Any,
Expand All @@ -45,12 +47,16 @@ def __init__(
try:
if self.debug:
log.msg(f"Creating an OAuthClient for {backend}.")
oauth_client = OAuthClientMap[backend](
oauth_backend = OAuthClientMap[backend]
oauth_backend_args = inspect.getfullargspec(
oauth_backend.__init__ # type: ignore
).args
oauth_client = oauth_backend(
client_id=client_id,
client_secret=client_secret,
debug=debug,
uid_cache=uid_cache,
**kwargs,
**{k: v for k, v in kwargs.items() if k in oauth_backend_args},
)
except Exception as exc:
msg = f"Could not construct an OAuth client for the '{backend}' backend.\n{exc!s}"
Expand All @@ -59,7 +65,9 @@ def __init__(
# Create an LDAPServerFactory
if self.debug:
log.msg("Creating an LDAPServerFactory.")
factory = OAuthLDAPServerFactory(domain, oauth_client)
factory = OAuthLDAPServerFactory(
domain, oauth_client, enable_mirrored_groups=enable_mirrored_groups
)

# Attach a listening endpoint
if self.debug:
Expand Down
2 changes: 1 addition & 1 deletion apricot/cache/redis_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class RedisCache(UidCache):
def __init__(self, redis_host: str, redis_port: int) -> None:
self.redis_host = redis_host
self.redis_port = redis_port
self.cache_: "redis.Redis[str]" | None = None
self.cache_: "redis.Redis[str]" | None = None # noqa: UP037

@property
def cache(self) -> "redis.Redis[str]":
Expand Down
28 changes: 28 additions & 0 deletions apricot/cache/uid_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,31 @@ def _get_max_uid(self, category: str | None) -> int:
keys = self.keys()
values = [*self.values(keys), -999]
return max(values)

def overwrite_group_uid(self, identifier: str, uid: int) -> None:
"""
Set UID for a group, overwriting the existing value if there is one

@param identifier: Identifier for group
@param uid: Desired UID
"""
return self.overwrite_uid(identifier, category="group", uid=uid)

def overwrite_user_uid(self, identifier: str, uid: int) -> None:
"""
Get UID for a user, constructing one if necessary

@param identifier: Identifier for user
@param uid: Desired UID
"""
return self.overwrite_uid(identifier, category="user", uid=uid)

def overwrite_uid(self, identifier: str, category: str, uid: int) -> None:
"""
Set UID, overwriting the existing one if necessary.

@param identifier: Identifier for object
@param category: Category the object belongs to
@param uid: Desired UID
"""
self.set(f"{category}-{identifier}", uid)
8 changes: 6 additions & 2 deletions apricot/ldap/oauth_ldap_server_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@


class OAuthLDAPServerFactory(ServerFactory):
def __init__(self, domain: str, oauth_client: OAuthClient):
def __init__(
self, domain: str, oauth_client: OAuthClient, *, enable_mirrored_groups: bool
):
"""
Initialise an LDAPServerFactory

@param oauth_client: An OAuth client used to construct the LDAP tree
"""
# Create an LDAP lookup tree
self.adaptor = OAuthLDAPTree(domain, oauth_client)
self.adaptor = OAuthLDAPTree(
domain, oauth_client, enable_mirrored_groups=enable_mirrored_groups
)

def __repr__(self) -> str:
return f"{self.__class__.__name__} using adaptor {self.adaptor}"
Expand Down
14 changes: 12 additions & 2 deletions apricot/ldap/oauth_ldap_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
class OAuthLDAPTree:

def __init__(
self, domain: str, oauth_client: OAuthClient, refresh_interval: int = 60
self,
domain: str,
oauth_client: OAuthClient,
*,
enable_mirrored_groups: bool,
refresh_interval: int = 60,
) -> None:
"""
Initialise an OAuthLDAPTree
Expand All @@ -29,6 +34,7 @@ def __init__(
self.oauth_client = oauth_client
self.refresh_interval = refresh_interval
self.root_: OAuthLDAPEntry | None = None
self.enable_mirrored_groups = enable_mirrored_groups

@property
def dn(self) -> DistinguishedName:
Expand All @@ -47,7 +53,11 @@ def root(self) -> OAuthLDAPEntry:
):
# Update users and groups from the OAuth server
log.msg("Retrieving OAuth data.")
oauth_adaptor = OAuthDataAdaptor(self.domain, self.oauth_client)
oauth_adaptor = OAuthDataAdaptor(
self.domain,
self.oauth_client,
enable_mirrored_groups=self.enable_mirrored_groups,
)

# Create a root node for the tree
log.msg("Rebuilding LDAP tree.")
Expand Down
1 change: 1 addition & 0 deletions apricot/models/ldap_attribute_adaptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ def __init__(self, attributes: dict[Any, Any]) -> None:
self.attributes = {
str(k): list(map(str, v)) if isinstance(v, list) else [str(v)]
for k, v in attributes.items()
if v is not None
}

@property
Expand Down
7 changes: 5 additions & 2 deletions apricot/models/ldap_inetorgperson.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ class LDAPInetOrgPerson(LDAPOrganizationalPerson):
"""

cn: str
displayName: str # noqa: N815
givenName: str # noqa: N815
displayName: str | None = None # noqa: N815
employeeNumber: str | None = None # noqa: N815
givenName: str | None = None # noqa: N815
sn: str
mail: str | None = None
telephoneNumber: str | None = None # noqa: N815

def names(self) -> list[str]:
return [*super().names(), "inetOrgPerson"]
6 changes: 5 additions & 1 deletion apricot/oauth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from apricot.types import LDAPAttributeDict, LDAPControlTuple

from .enums import OAuthBackend
from .keycloak_client import KeycloakClient
from .microsoft_entra_client import MicrosoftEntraClient
from .oauth_client import OAuthClient
from .oauth_data_adaptor import OAuthDataAdaptor

OAuthClientMap = {OAuthBackend.MICROSOFT_ENTRA: MicrosoftEntraClient}
OAuthClientMap = {
OAuthBackend.MICROSOFT_ENTRA: MicrosoftEntraClient,
OAuthBackend.KEYCLOAK: KeycloakClient,
}

__all__ = [
"LDAPAttributeDict",
Expand Down
1 change: 1 addition & 0 deletions apricot/oauth/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ class OAuthBackend(str, Enum):
"""Available OAuth backends."""

MICROSOFT_ENTRA = "MicrosoftEntra"
KEYCLOAK = "Keycloak"
Loading
Loading