Skip to content

Commit

Permalink
Merge pull request #37 from BlackVoid/feature/keycloak
Browse files Browse the repository at this point in the history
Add support for keycloak and option to disable group-of-groups
  • Loading branch information
jemrobinson authored May 30, 2024
2 parents 9d13b8f + 8cca5d6 commit ff7ed85
Show file tree
Hide file tree
Showing 16 changed files with 355 additions and 38 deletions.
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

0 comments on commit ff7ed85

Please sign in to comment.