Skip to content

Commit

Permalink
add support for team-based access control (#1587)
Browse files Browse the repository at this point in the history
this just follows the preexisting pattern, so you just add an ACL like
"team:Operator Team"
  • Loading branch information
srabraham authored Feb 8, 2025
1 parent 629f5ca commit 4eacea6
Show file tree
Hide file tree
Showing 13 changed files with 262 additions and 14 deletions.
9 changes: 9 additions & 0 deletions src/ims/auth/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
DirectoryUser,
IMSDirectory,
IMSGroupID,
IMSTeamID,
IMSUser,
IMSUserID,
)
Expand Down Expand Up @@ -127,6 +128,8 @@ def _now(now: float | None) -> float:
ranger_on_site: bool = field(validator=instance_of(bool))
# positions
ranger_positions: str = field(validator=instance_of(str))
# teams
ranger_teams: str = field(validator=instance_of(str))

def validateIssuer(self, issuer: str) -> None:
"""
Expand Down Expand Up @@ -257,6 +260,7 @@ def _tokenForUser(self, user: IMSUser, duration: TimeDelta) -> JSONWebToken:
preferred_username=user.shortNames[0],
ranger_on_site=user.onsite,
ranger_positions=",".join(user.groups),
ranger_teams=",".join(user.teams),
),
key=self._jsonWebKey,
)
Expand Down Expand Up @@ -300,6 +304,7 @@ def _userFromBearerAuthorization(self, authorization: str | None) -> IMSUser | N
shortNames=(claims.preferred_username,),
onsite=claims.ranger_on_site,
groups=tuple(IMSGroupID(gid) for gid in claims.ranger_positions.split(",")),
teams=tuple(IMSTeamID(tid) for tid in claims.ranger_teams.split(",")),
)

def _enhanceSessionCookie(self, request: IRequest) -> None:
Expand Down Expand Up @@ -388,6 +393,10 @@ def _matchACL(self, user: IMSUser | None, acl: Iterable[AccessEntry]) -> bool:
if a.expression == "position:" + group:
return True

for team in user.teams:
if a.expression == "team:" + team:
return True

return False

async def authorizationsForUser(
Expand Down
58 changes: 52 additions & 6 deletions src/ims/auth/test/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@

from ...directory import (
IMSGroupID,
IMSTeamID,
IMSUser,
IMSUserID,
hashPassword,
Expand Down Expand Up @@ -86,6 +87,7 @@ class TestUser(IMSUser):
shortNames: Sequence[str]
onsite: bool
groups: Sequence[IMSGroupID]
teams: Sequence[IMSTeamID]
plainTextPassword: str | None

@property
Expand All @@ -105,6 +107,10 @@ def testUsers(draw: Callable[..., Any]) -> TestUser:
IMSGroupID(g)
for g in draw(text(min_size=1, alphabet=ascii_letters + digits + "_"))
),
teams=tuple(
IMSTeamID(t)
for t in draw(text(min_size=1, alphabet=ascii_letters + digits + "_"))
),
plainTextPassword=draw(one_of(none(), text())),
)

Expand Down Expand Up @@ -133,6 +139,7 @@ def test_oops(self) -> None:
lists(text(min_size=1), min_size=1),
booleans(),
lists(text(min_size=1)),
lists(text(min_size=1)),
text(),
)
def test_testUser(
Expand All @@ -141,23 +148,27 @@ def test_testUser(
shortNames: Sequence[str],
active: bool,
_groups: Sequence[str],
_teams: Sequence[str],
password: str,
) -> None:
uid = IMSUserID(_uid)
groups = tuple(IMSGroupID(g) for g in _groups)
teams = tuple(IMSTeamID(t) for t in _teams)

user = TestUser(
uid=uid,
shortNames=shortNames,
onsite=active,
groups=groups,
teams=teams,
plainTextPassword=password,
)

self.assertEqual(user.uid, uid)
self.assertEqual(tuple(user.shortNames), tuple(shortNames))
self.assertEqual(user.onsite, active)
self.assertEqual(tuple(user.groups), tuple(groups))
self.assertEqual(tuple(user.teams), tuple(teams))
self.assertEqual(user.plainTextPassword, password)

if user.plainTextPassword is not None:
Expand Down Expand Up @@ -209,6 +220,7 @@ def token(self, **kwargs: Any) -> JSONWebTokenClaims:
"preferred_username": "some-user",
"ranger_on_site": True,
"ranger_positions": "some-position,another-position",
"ranger_teams": "some-team,another-team",
}
defaults.update(kwargs)
return JSONWebTokenClaims(**defaults)
Expand Down Expand Up @@ -263,13 +275,15 @@ class JSONWebTokenTests(TestCase):
# "preferred_username": "some-user",
# "ranger_on_site": true,
# "ranger_positions": "some-position,another-position"
# "ranger_teams: "some-team,another-team"
# }
tokenText = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJteS1pc3N1ZXIiLC"
"JpYXQiOjEwMDAwMDAwMDAsImV4cCI6NTAwMDAwMDAwMCwic3ViIjoic29tZS11a"
"WQiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzb21lLXVzZXIiLCJyYW5nZXJfb25f"
"c2l0ZSI6dHJ1ZSwicmFuZ2VyX3Bvc2l0aW9ucyI6InNvbWUtcG9zaXRpb24sYW5"
"vdGhlci1wb3NpdGlvbiJ9.xFkqa5ZSejA0RGmwuPtiYwjsPyjubXwKwdqhuwOiS8w"
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjUwMDAwMDAwMDAsIml"
"hdCI6MTAwMDAwMDAwMCwiaXNzIjoibXktaXNzdWVyIiwicHJlZmVycmVkX3VzZXJ"
"uYW1lIjoic29tZS11c2VyIiwicmFuZ2VyX29uX3NpdGUiOnRydWUsInJhbmdlcl9"
"wb3NpdGlvbnMiOiJzb21lLXBvc2l0aW9uLGFub3RoZXItcG9zaXRpb24iLCJyYW5"
"nZXJfdGVhbXMiOiJzb21lLXRlYW0sYW5vdGhlci10ZWFtIiwic3ViIjoic29tZS1"
"1aWQifQ.ScII96a00V8Xal_JZGDTKeM6ky_1GkUfY69IL7DXOdU"
)
tokenSecret = "sekret"

Expand All @@ -289,6 +303,7 @@ def test_fromText(self) -> None:
self.assertEqual(claims.preferred_username, "some-user")
self.assertEqual(claims.ranger_on_site, True)
self.assertEqual(claims.ranger_positions, "some-position,another-position")
self.assertEqual(claims.ranger_teams, "some-team,another-team")

def test_fromClaims(self) -> None:
"""
Expand All @@ -304,8 +319,9 @@ def test_fromClaims(self) -> None:
preferred_username="some-user",
ranger_on_site=True,
ranger_positions="some-position,another-position",
ranger_teams="some-team,another-team",
),
key=JSONWebKey.fromSecret("blah"),
key=JSONWebKey.fromSecret("sekret"),
)
claims = jwt.claims

Expand All @@ -316,6 +332,7 @@ def test_fromClaims(self) -> None:
self.assertEqual(claims.preferred_username, "some-user")
self.assertEqual(claims.ranger_on_site, True)
self.assertEqual(claims.ranger_positions, "some-position,another-position")
self.assertEqual(claims.ranger_teams, "some-team,another-team")

def test_fromText_wrongKey(self) -> None:
"""
Expand All @@ -340,6 +357,7 @@ def test_asText(self) -> None:
preferred_username="some-user",
ranger_on_site=True,
ranger_positions="some-position,another-position",
ranger_teams="some-team,another-team",
)
key = JSONWebKey.fromSecret(self.tokenSecret)
jwt = JSONWebToken.fromClaims(claims, key=key)
Expand Down Expand Up @@ -460,6 +478,7 @@ def approximateTimestamps(a: float, b: float) -> bool:
self.assertEqual(claims.preferred_username, user.shortNames[0])
self.assertEqual(claims.ranger_on_site, user.onsite)
self.assertEqual(claims.ranger_positions, ",".join(user.groups))
self.assertEqual(claims.ranger_teams, ",".join(user.teams))

@given(
testUsers(),
Expand Down Expand Up @@ -699,6 +718,30 @@ def test_matchACL_position(self, user: IMSUser) -> None:
)
)

@given(testUsers())
def test_matchACL_team(self, user: IMSUser) -> None:
"""
AuthProvider._matchACL matches team access with a matching user.
"""
provider = AuthProvider(
store=self.store(),
directory=self.directory(),
jsonWebKey=JSONWebKey.generate(),
)

for teamID in user.teams:
self.assertTrue(
provider._matchACL(
user,
(
AccessEntry(
expression=f"team:{teamID}",
validity=AccessValidity.always,
),
),
)
)

def test_matchACL_person_notOnsite(self) -> None:
"""
AuthProvider._matchACL won't match for off-site user if on-site required.
Expand All @@ -714,6 +757,7 @@ def test_matchACL_person_notOnsite(self) -> None:
shortNames=("Slumber",),
onsite=False,
groups=(),
teams=(),
plainTextPassword="some-password",
)

Expand Down Expand Up @@ -753,6 +797,7 @@ def test_authorizeRequest(self) -> None:
shortNames=("Slumber",),
onsite=True,
groups=(),
teams=(),
plainTextPassword="some-password",
)
personUser = f"person:{user.shortNames[0]}"
Expand Down Expand Up @@ -868,6 +913,7 @@ def test_authorizeReqForFieldReport(self) -> None:
shortNames=("Slumber",),
onsite=True,
groups=(),
teams=(),
plainTextPassword="some-password",
)
personUser = f"person:{user.shortNames[0]}"
Expand Down
2 changes: 2 additions & 0 deletions src/ims/directory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
DirectoryUser,
IMSDirectory,
IMSGroupID,
IMSTeamID,
IMSUser,
IMSUserID,
hashPassword,
Expand All @@ -36,6 +37,7 @@
"DirectoryUser",
"IMSDirectory",
"IMSGroupID",
"IMSTeamID",
"IMSUser",
"IMSUserID",
"hashPassword",
Expand Down
17 changes: 14 additions & 3 deletions src/ims/directory/_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@
from attrs import field, frozen, mutable
from bcrypt import gensalt

from ims.model import Position, Ranger
from ims.model import Position, Ranger, Team


__all__ = ()


IMSUserID = NewType("IMSUserID", str)
IMSGroupID = NewType("IMSGroupID", str)
IMSTeamID = NewType("IMSTeamID", str)


@mutable
Expand All @@ -54,6 +55,7 @@ class IMSUser(Protocol):
shortNames: Sequence[str]
onsite: bool
groups: Sequence[IMSGroupID]
teams: Sequence[IMSTeamID]
hashedPassword: str | None


Expand All @@ -67,12 +69,15 @@ class DirectoryUser(IMSUser):
shortNames: Sequence[str]
onsite: bool
groups: Sequence[IMSGroupID]
teams: Sequence[IMSTeamID]
hashedPassword: str | None = field(
default=None, repr=lambda _p: "\N{ZIPPER-MOUTH FACE}"
)


def userFromRanger(*, ranger: Ranger, groups: Sequence[IMSGroupID]) -> IMSUser:
def userFromRanger(
*, ranger: Ranger, groups: Sequence[IMSGroupID], teams: Sequence[IMSTeamID]
) -> IMSUser:
"""
Create an IMS user from a Ranger.
"""
Expand All @@ -81,6 +86,7 @@ def userFromRanger(*, ranger: Ranger, groups: Sequence[IMSGroupID]) -> IMSUser:
shortNames=(ranger.handle,),
onsite=ranger.onsite,
groups=tuple(groups),
teams=tuple(teams),
hashedPassword=ranger.password,
)

Expand Down Expand Up @@ -119,6 +125,7 @@ class RangerDirectory(IMSDirectory):
_usersByHandle: dict[str, IMSUser] = field(factory=dict)
_usersByEmail: dict[str, IMSUser] = field(factory=dict)
_positionsByHandle: dict[str, Sequence[Position]] = field(factory=dict)
_teamsByHandle: dict[str, Sequence[Team]] = field(factory=dict)

def __attrs_post_init__(self) -> None:
usersByHandle = self._usersByHandle
Expand All @@ -139,7 +146,11 @@ def __attrs_post_init__(self) -> None:
IMSGroupID(position.name)
for position in self._positionsByHandle.get(ranger.handle, ())
)
user = userFromRanger(ranger=ranger, groups=groups)
teams = tuple(
IMSTeamID(team.name)
for team in self._teamsByHandle.get(ranger.handle, ())
)
user = userFromRanger(ranger=ranger, groups=groups, teams=teams)

usersByHandle[ranger.handle] = user

Expand Down
9 changes: 7 additions & 2 deletions src/ims/directory/clubhouse_db/_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from attrs import frozen
from twisted.logger import Logger

from ims.directory import IMSDirectory, IMSGroupID, IMSUser, userFromRanger
from ims.directory import IMSDirectory, IMSGroupID, IMSTeamID, IMSUser, userFromRanger
from ims.model import Ranger

from ._dms import DutyManagementSystem
Expand Down Expand Up @@ -63,11 +63,16 @@ async def lookupUser(self, searchTerm: str) -> IMSUser | None:
return None

positions = tuple(await dms.positions())
teams = tuple(await dms.teams())

groups = tuple(
IMSGroupID(position.name)
for position in positions
if ranger in position.members
)

return userFromRanger(ranger=ranger, groups=groups)
imsTeams = tuple(
IMSTeamID(team.name) for team in teams if ranger in team.members
)

return userFromRanger(ranger=ranger, groups=groups, teams=imsTeams)
Loading

0 comments on commit 4eacea6

Please sign in to comment.