Skip to content

Initial KeyCloak integration #105

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

Merged
merged 38 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
06533f4
bump version
rambo Jul 6, 2024
94975e3
force ipv4 resolution for localmaeher names
rambo Jul 6, 2024
4c8c025
deprecate old instructions APIs, start working on new ones
rambo Jul 6, 2024
a80ecb4
move descriptions endpoints to their own url space and file, add test
rambo Jul 6, 2024
5fc7d20
refactor the productapi helpers to support single product requests
rambo Jul 6, 2024
4bf6d03
add api and test for getting single products description
rambo Jul 6, 2024
e4ad9c6
add endpoint and test for getting product instructions
rambo Jul 6, 2024
83464de
start working on a REST client for KC integration
rambo Jul 7, 2024
db59308
start integrating with KC REST API
rambo Jul 7, 2024
295288b
user create and delete in KC works
rambo Jul 8, 2024
962be66
switch to python-keycloak for KC REST
rambo Jul 8, 2024
a537252
Add support for assigning/removing admin role in KC too
rambo Jul 8, 2024
7d87555
fix TypeError: argument of type 'NoneType' is not iterable
rambo Sep 7, 2024
a3ea0a8
handle new user properties
rambo Sep 7, 2024
4e744ac
use case-insensitive callsign search for enrollments, refs #107
rambo Sep 8, 2024
0c2feed
do not copy back properties we already have locally
rambo Sep 8, 2024
c959815
fix the user update payload, use callsign property to instantiate fro…
rambo Sep 8, 2024
e96f949
Make callsign queries case-insensitive, fixes #107
rambo Sep 8, 2024
ac93f77
make enrollment code size configurable
rambo Sep 8, 2024
643a07c
clean up connection auto-acquire
rambo Nov 16, 2024
ea4aed7
move the refresh inside the connection context manager where it shoul…
rambo Nov 16, 2024
dbbb21d
move localmaeher to the dev namespace so we can manage the records
rambo Nov 16, 2024
abc8996
populate product specific alternate usernames
rambo Nov 16, 2024
d84e052
ignore notebooks
rambo Nov 17, 2024
3ed7b3b
make product specific initial groups and assign new users to them
rambo Nov 17, 2024
4d0018a
do not addign anon_admin to any groups
rambo Nov 17, 2024
975ee67
For LDAP group names must be unique :(
rambo Nov 17, 2024
16472bc
Attempt to work around a race condition with first admin
rambo Nov 17, 2024
594761c
update deps
rambo Nov 17, 2024
40aa755
remove duplicated fixture that came from merge
rambo Nov 17, 2024
142accc
make async fixtures scope to session by default
rambo Nov 17, 2024
750ba9b
set async test event loop scope as per migration guide
rambo Nov 17, 2024
5f61ddb
explicitly set loop scope on async tests
rambo Nov 19, 2024
af30b63
remove the old weird testclient fixture
rambo Nov 19, 2024
3ddbb87
use single session scoped app instance
rambo Nov 19, 2024
6c1fd86
log lifespan events
rambo Nov 19, 2024
08a0e44
skip tests that get weird because asyncio/asyncpg/gino, we have to tr…
rambo Jan 24, 2025
88864b7
update deps Snyk complains about
rambo Jan 24, 2025
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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.4.0
current_version = 1.5.0
commit = False
tag = False

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# IDE settings
.idea

*.ipynb


# ci artefacts
pytest*.xml

Expand Down
5 changes: 3 additions & 2 deletions docker/container-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
set -e
# Make sure fakeproduct api endpoint points to correct IP, 127.0.01 is this containers localhost...
sed 's/.*localmaeher.*//g' /etc/hosts >/etc/hosts.new && cat /etc/hosts.new >/etc/hosts
echo "$(getent hosts host.docker.internal | awk '{ print $1 }') fake.localmaeher.pvarki.fi" >>/etc/hosts
echo "$(getent hosts host.docker.internal | awk '{ print $1 }') tak.localmaeher.pvarki.fi" >>/etc/hosts
echo "$(getent ahostsv4 host.docker.internal | awk '{ print $1 }') fake.localmaeher.dev.pvarki.fi" >>/etc/hosts
echo "$(getent ahostsv4 host.docker.internal | awk '{ print $1 }') tak.localmaeher.dev.pvarki.fi" >>/etc/hosts
echo "$(getent ahostsv4 host.docker.internal | awk '{ print $1 }') kc.localmaeher.dev.pvarki.fi" >>/etc/hosts

# Make sure the persistent directories exist
test -d /data/persistent/private || ( mkdir -p /data/persistent/private && chmod og-rwx /data/persistent/private )
Expand Down
2,404 changes: 1,343 additions & 1,061 deletions poetry.lock

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "rasenmaeher_api"
version = "1.4.0"
version = "1.5.0"
description = "python-rasenmaeher-api"
authors = [
"Aciid <703382+Aciid@users.noreply.github.com>",
Expand Down Expand Up @@ -47,6 +47,7 @@ disallow_untyped_decorators=false
junit_family="xunit2"
addopts="--cov=rasenmaeher_api --cov-fail-under=65 --cov-branch"
asyncio_mode="strict"
asyncio_default_fixture_loop_scope = "session"

[tool.pylint.MASTER]
extension-pkg-allow-list = "pydantic"
Expand Down Expand Up @@ -84,12 +85,14 @@ pyopenssl = "^23.1"
libpvarki = { git="https://github.com/pvarki/python-libpvarki.git", tag="1.9.0"}
openapi-readme = "^0.2"
python-multipart = "^0.0.6"
aiohttp = "^3.8"
aiohttp = ">=3.11.10,<4.0"
pyjwt = ">=2.10.1"
aiodns = "^3.0"
brotli = "^1.0"
cchardet = { version="^2.1", python="<=3.10"}
filelock = "^3.12"
gino = "^1.0"
python-keycloak = "^4.2.0"

[tool.poetry.group.dev.dependencies]
pytest = "^7.4"
Expand All @@ -100,7 +103,7 @@ black = "^23.7"
bandit = "^1.7"
mypy = "^1.5"
pre-commit = "^3.3"
pytest-asyncio = ">=0.21,<1.0" # caret behaviour on 0.x is to lock to 0.x.*
pytest-asyncio = ">=0.23,<1.0" # caret behaviour on 0.x is to lock to 0.x.*
bump2version = "^1.0"
detect-secrets = "^1.2"
httpx = ">=0.23,<1.0" # caret behaviour on 0.x is to lock to 0.x.*
Expand Down
2 changes: 1 addition & 1 deletion src/rasenmaeher_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
""" python-rasenmaeher-api """
__version__ = "1.4.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly
__version__ = "1.5.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly
17 changes: 13 additions & 4 deletions src/rasenmaeher_api/db/enrollments.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,27 @@
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as saUUID
import sqlalchemy as sa
from sqlalchemy.sql import func

from .base import ORMBaseModel, utcnow, db
from .people import Person
from .errors import ForbiddenOperation, CallsignReserved, NotFound, Deleted, PoolInactive
from ..rmsettings import RMSettings

LOGGER = logging.getLogger(__name__)
CODE_CHAR_COUNT = 8 # TODO: Make configurable ??
CODE_ALPHABET = string.ascii_uppercase + string.digits
CODE_MAX_ATTEMPTS = 100


def generate_code() -> str:
"""Generate a code"""
settings = RMSettings.singleton()
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(settings.code_size))
if settings.code_avoid_confusion:
code = code.replace("0", "O").replace("1", "I")
return code


class EnrollmentPool(ORMBaseModel): # pylint: disable=R0903
"""Enrollment pools aka links, pk is UUID and comes from basemodel"""

Expand Down Expand Up @@ -81,7 +90,7 @@ async def _generate_unused_code(cls) -> str:
attempt = 0
while True:
attempt += 1
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
code = generate_code()
try:
await EnrollmentPool.by_invitecode(code)
except NotFound:
Expand Down Expand Up @@ -184,7 +193,7 @@ async def list(cls, by_pool: Optional[EnrollmentPool] = None) -> AsyncGenerator[
@classmethod
async def by_callsign(cls, callsign: str) -> Self:
"""Get by callsign"""
obj = await Enrollment.query.where(Enrollment.callsign == callsign).gino.first()
obj = await Enrollment.query.where(func.lower(Enrollment.callsign) == func.lower(callsign)).gino.first()
if not obj:
raise NotFound()
if obj.deleted:
Expand Down Expand Up @@ -227,7 +236,7 @@ async def _generate_unused_code(cls) -> str:
attempt = 0
while True:
attempt += 1
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
code = generate_code()
try:
await Enrollment.by_approvecode(code)
except NotFound:
Expand Down
54 changes: 30 additions & 24 deletions src/rasenmaeher_api/db/logincodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import sqlalchemy as sa
from multikeyjwt import Issuer

from .base import ORMBaseModel, utcnow
from .base import ORMBaseModel, utcnow, db
from .errors import ForbiddenOperation, NotFound, Deleted, TokenReuse

LOGGER = logging.getLogger(__name__)
Expand All @@ -30,19 +30,23 @@ class LoginCode(ORMBaseModel): # pylint: disable=R0903
@classmethod
async def use_code(cls, code: str, auditmeta: Optional[Dict[str, Any]] = None) -> str:
"""Exchange the code for JWT, if it was already used raise error that is also 403, return JWT with the claims"""
obj = await LoginCode.by_code(code)
if obj.used_on:
LOGGER.error("{} was used on {}".format(obj.code, obj.used_on))
raise TokenReuse()
if not auditmeta:
auditmeta = {}
await obj.update(used_on=utcnow, auditmeta=auditmeta).apply()
return Issuer.singleton().issue(obj.claims)
async with db.acquire() as conn:
async with conn.transaction(): # do it in a transaction to avoid data races
obj = await LoginCode.by_code(code)
if obj.used_on:
LOGGER.error("{} was used on {}".format(obj.code, obj.used_on))
raise TokenReuse()
if not auditmeta:
auditmeta = {}
await obj.update(used_on=utcnow, auditmeta=auditmeta).apply()
return Issuer.singleton().issue(obj.claims)

@classmethod
async def by_code(cls, code: str) -> Self:
"""Get by token"""
obj = await LoginCode.query.where(LoginCode.code == code).gino.first()
async with db.acquire() as conn:
async with conn.transaction(): # do it in a transaction to avoid data races
obj = await LoginCode.query.where(LoginCode.code == code).gino.first()
if not obj:
raise NotFound()
if obj.deleted:
Expand All @@ -55,20 +59,22 @@ async def create_for_claims(cls, claims: Dict[str, Any], auditmeta: Optional[Dic
"""Create a new one with random code for the given claims"""
# TODO: Do this in a transaction to avoid race conditions
attempt = 0
while True:
attempt += 1
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
try:
await LoginCode.by_code(code)
except NotFound:
break
if attempt > CODE_MAX_ATTEMPTS:
raise RuntimeError("Can't find unused code")
if not auditmeta:
auditmeta = {}
dbobj = LoginCode(code=code, claims=claims, auditmeta=auditmeta)
await dbobj.create()
return code
async with db.acquire() as conn:
async with conn.transaction(): # do it in a transaction to avoid data races
while True:
attempt += 1
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
try:
await LoginCode.by_code(code)
except NotFound:
break
if attempt > CODE_MAX_ATTEMPTS:
raise RuntimeError("Can't find unused code")
if not auditmeta:
auditmeta = {}
dbobj = LoginCode(code=code, claims=claims, auditmeta=auditmeta)
await dbobj.create()
return code

async def delete(self) -> bool:
"""Deletion of enrollments is not allowed"""
Expand Down
7 changes: 1 addition & 6 deletions src/rasenmaeher_api/db/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,5 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
return

# Get and release connection
scope["connection"] = await self.gino.acquire(lazy=True)
try:
async with self.gino.acquire(lazy=True):
await self.app(scope, receive, send)
finally:
conn = scope.pop("connection", None)
if conn is not None:
await conn.release()
Loading
Loading