From 4eacea624623650252016cf48998f5a0582a3025 Mon Sep 17 00:00:00 2001 From: "Sean R. Abraham" Date: Sat, 8 Feb 2025 11:53:43 -0500 Subject: [PATCH] add support for team-based access control (#1587) this just follows the preexisting pattern, so you just add an ACL like "team:Operator Team" --- src/ims/auth/_provider.py | 9 +++ src/ims/auth/test/test_provider.py | 58 +++++++++++++++++-- src/ims/directory/__init__.py | 2 + src/ims/directory/_directory.py | 17 +++++- src/ims/directory/clubhouse_db/_directory.py | 9 ++- src/ims/directory/clubhouse_db/_dms.py | 60 +++++++++++++++++++- src/ims/directory/clubhouse_db/test/dummy.py | 6 ++ src/ims/directory/test/test_directory.py | 10 ++++ src/ims/element/static/admin_events.js | 2 +- src/ims/model/__init__.py | 2 + src/ims/model/_team.py | 43 ++++++++++++++ src/ims/model/strategies.py | 16 ++++++ src/ims/model/test/test_team.py | 42 ++++++++++++++ 13 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 src/ims/model/_team.py create mode 100644 src/ims/model/test/test_team.py diff --git a/src/ims/auth/_provider.py b/src/ims/auth/_provider.py index d348cfac3..04b0afef4 100644 --- a/src/ims/auth/_provider.py +++ b/src/ims/auth/_provider.py @@ -39,6 +39,7 @@ DirectoryUser, IMSDirectory, IMSGroupID, + IMSTeamID, IMSUser, IMSUserID, ) @@ -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: """ @@ -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, ) @@ -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: @@ -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( diff --git a/src/ims/auth/test/test_provider.py b/src/ims/auth/test/test_provider.py index 78779a099..aa1af27ea 100644 --- a/src/ims/auth/test/test_provider.py +++ b/src/ims/auth/test/test_provider.py @@ -51,6 +51,7 @@ from ...directory import ( IMSGroupID, + IMSTeamID, IMSUser, IMSUserID, hashPassword, @@ -86,6 +87,7 @@ class TestUser(IMSUser): shortNames: Sequence[str] onsite: bool groups: Sequence[IMSGroupID] + teams: Sequence[IMSTeamID] plainTextPassword: str | None @property @@ -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())), ) @@ -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( @@ -141,16 +148,19 @@ 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, ) @@ -158,6 +168,7 @@ def test_testUser( 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: @@ -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) @@ -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" @@ -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: """ @@ -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 @@ -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: """ @@ -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) @@ -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(), @@ -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. @@ -714,6 +757,7 @@ def test_matchACL_person_notOnsite(self) -> None: shortNames=("Slumber",), onsite=False, groups=(), + teams=(), plainTextPassword="some-password", ) @@ -753,6 +797,7 @@ def test_authorizeRequest(self) -> None: shortNames=("Slumber",), onsite=True, groups=(), + teams=(), plainTextPassword="some-password", ) personUser = f"person:{user.shortNames[0]}" @@ -868,6 +913,7 @@ def test_authorizeReqForFieldReport(self) -> None: shortNames=("Slumber",), onsite=True, groups=(), + teams=(), plainTextPassword="some-password", ) personUser = f"person:{user.shortNames[0]}" diff --git a/src/ims/directory/__init__.py b/src/ims/directory/__init__.py index 2ae73d604..b9b2e1c24 100644 --- a/src/ims/directory/__init__.py +++ b/src/ims/directory/__init__.py @@ -23,6 +23,7 @@ DirectoryUser, IMSDirectory, IMSGroupID, + IMSTeamID, IMSUser, IMSUserID, hashPassword, @@ -36,6 +37,7 @@ "DirectoryUser", "IMSDirectory", "IMSGroupID", + "IMSTeamID", "IMSUser", "IMSUserID", "hashPassword", diff --git a/src/ims/directory/_directory.py b/src/ims/directory/_directory.py index 3a6f5497e..a12bb540e 100644 --- a/src/ims/directory/_directory.py +++ b/src/ims/directory/_directory.py @@ -26,7 +26,7 @@ from attrs import field, frozen, mutable from bcrypt import gensalt -from ims.model import Position, Ranger +from ims.model import Position, Ranger, Team __all__ = () @@ -34,6 +34,7 @@ IMSUserID = NewType("IMSUserID", str) IMSGroupID = NewType("IMSGroupID", str) +IMSTeamID = NewType("IMSTeamID", str) @mutable @@ -54,6 +55,7 @@ class IMSUser(Protocol): shortNames: Sequence[str] onsite: bool groups: Sequence[IMSGroupID] + teams: Sequence[IMSTeamID] hashedPassword: str | None @@ -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. """ @@ -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, ) @@ -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 @@ -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 diff --git a/src/ims/directory/clubhouse_db/_directory.py b/src/ims/directory/clubhouse_db/_directory.py index 16f4b278d..0d3eea0ff 100644 --- a/src/ims/directory/clubhouse_db/_directory.py +++ b/src/ims/directory/clubhouse_db/_directory.py @@ -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 @@ -63,6 +63,7 @@ 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) @@ -70,4 +71,8 @@ async def lookupUser(self, searchTerm: str) -> IMSUser | None: 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) diff --git a/src/ims/directory/clubhouse_db/_dms.py b/src/ims/directory/clubhouse_db/_dms.py index af0e527d6..f3027477c 100644 --- a/src/ims/directory/clubhouse_db/_dms.py +++ b/src/ims/directory/clubhouse_db/_dms.py @@ -64,6 +64,17 @@ class Position: members: set[Ranger] = field(factory=set) +@frozen +class Team: + """ + A Ranger team. + """ + + teamID: str + name: str + members: set[Ranger] = field(factory=set) + + @frozen(kw_only=True, eq=False) class DutyManagementSystem: """ @@ -82,6 +93,7 @@ class _State: _personnel: Iterable[Ranger] = field(default=(), init=False) _positions: Iterable[Position] = field(default=(), init=False) + _teams: Iterable[Team] = field(default=(), init=False) _personnelLastUpdated: float = field(default=0.0, init=False) _dbpool: adbapi.ConnectionPool | None = field(default=None, init=False) _busy: bool = field(default=False, init=False) @@ -138,6 +150,17 @@ async def _queryPositionsByID(self) -> Mapping[str, Position]: return {id: Position(positionID=id, name=title) for (id, title) in rows} + async def _queryTeamsByID(self) -> Mapping[str, Team]: + self._log.info("Retrieving teams from Duty Management System...") + + sql = """ + select id, title from team + """ + self._log.debug("EXECUTE DMS: {sql}", sql=sql) + rows = await self.dbpool.runQuery(sql) + + return {id: Team(teamID=id, name=title) for (id, title) in rows} + async def _queryRangersByID(self) -> Mapping[str, Ranger]: self._log.info("Retrieving personnel from Duty Management System...") @@ -188,6 +211,20 @@ async def _queryPositionRangerJoin(self) -> Iterable[tuple[str, str]]: ), ) + async def _queryTeamRangerJoin(self) -> Iterable[tuple[str, str]]: + self._log.info( + "Retrieving team-personnel relations from Duty Management System..." + ) + + return cast( + Iterable[tuple[str, str]], + await self.dbpool.runQuery( + """ + select person_id, team_id from person_team + """ + ), + ) + async def positions(self) -> Iterable[Position]: """ Look up all positions. @@ -197,6 +234,13 @@ async def positions(self) -> Iterable[Position]: await self.personnel() return self._state._positions + async def teams(self) -> Iterable[Team]: + """ + Look up all teams. + """ + await self.personnel() + return self._state._teams + async def personnel(self) -> Iterable[Ranger]: """ Look up all personnel. @@ -210,9 +254,11 @@ async def personnel(self) -> Iterable[Ranger]: try: rangersByID = await self._queryRangersByID() positionsByID = await self._queryPositionsByID() - join = await self._queryPositionRangerJoin() + positionJoin = await self._queryPositionRangerJoin() + teamsByID = await self._queryTeamsByID() + teamJoin = await self._queryTeamRangerJoin() - for rangerID, positionID in join: + for rangerID, positionID in positionJoin: position = positionsByID.get(positionID, None) if position is None: continue @@ -221,8 +267,18 @@ async def personnel(self) -> Iterable[Ranger]: continue position.members.add(ranger) + for rangerID, teamID in teamJoin: + team = teamsByID.get(teamID, None) + if team is None: + continue + ranger = rangersByID.get(rangerID, None) + if ranger is None: + continue + team.members.add(ranger) + self._state._personnel = tuple(rangersByID.values()) self._state._positions = tuple(positionsByID.values()) + self._state._teams = tuple(teamsByID.values()) self._state._personnelLastUpdated = time() self._state._dbErrorCount = 0 diff --git a/src/ims/directory/clubhouse_db/test/dummy.py b/src/ims/directory/clubhouse_db/test/dummy.py index 8e8d777fb..ae5a54322 100644 --- a/src/ims/directory/clubhouse_db/test/dummy.py +++ b/src/ims/directory/clubhouse_db/test/dummy.py @@ -98,6 +98,12 @@ def fixPassword( if sql == ("select person_id, position_id from person_position"): return succeed(()) # type: ignore[arg-type] + if sql == ("select id, title from team"): + return succeed(()) # type: ignore[arg-type] + + if sql == ("select person_id, team_id from person_team"): + return succeed(()) # type: ignore[arg-type] + return fail(AssertionError(f"No canned response for query: {sql}")) diff --git a/src/ims/directory/test/test_directory.py b/src/ims/directory/test/test_directory.py index 7cfb74f75..a6b673500 100644 --- a/src/ims/directory/test/test_directory.py +++ b/src/ims/directory/test/test_directory.py @@ -32,6 +32,7 @@ from .._directory import ( DirectoryError, IMSGroupID, + IMSTeamID, IMSUser, RangerDirectory, _hash, @@ -52,6 +53,14 @@ def groupIDs(draw: Callable[..., Any]) -> Iterable[IMSGroupID]: ) +@composite +def teamIDs(draw: Callable[..., Any]) -> Iterable[IMSTeamID]: + return cast( + Iterable[IMSTeamID], + iterables(IMSTeamID(draw(text(min_size=1)))), + ) + + @composite def uniqueRangerLists(draw: Callable[..., Any]) -> RangerDirectory: return cast( @@ -65,6 +74,7 @@ def imsUsers(draw: Callable[..., Any]) -> IMSUser: return userFromRanger( ranger=draw(rangers()), groups=draw(groupIDs()), + teams=draw(teamIDs()), ) diff --git a/src/ims/element/static/admin_events.js b/src/ims/element/static/admin_events.js index 30f4dd34a..362c3d5e6 100644 --- a/src/ims/element/static/admin_events.js +++ b/src/ims/element/static/admin_events.js @@ -156,7 +156,7 @@ function addAccess(sender) { } const validExpression = newExpression === "**" || newExpression === "*" || - newExpression.startsWith("person:") || newExpression.startsWith("position:"); + newExpression.startsWith("person:") || newExpression.startsWith("position:") || newExpression.startsWith("team:"); if (!validExpression) { const confirmed = confirm( "WARNING: '" + newExpression + "' does not look like a valid ACL " + diff --git a/src/ims/model/__init__.py b/src/ims/model/__init__.py index aa082fa7e..35fdb0fa5 100644 --- a/src/ims/model/__init__.py +++ b/src/ims/model/__init__.py @@ -34,6 +34,7 @@ from ._ranger import Ranger, RangerStatus from ._report import FieldReport from ._state import IncidentState +from ._team import Team from ._type import IncidentType, KnownIncidentType @@ -57,6 +58,7 @@ "RangerStatus", "ReportEntry", "RodGarettAddress", + "Team", "TextOnlyAddress", "normalizeDateTime", ) diff --git a/src/ims/model/_team.py b/src/ims/model/_team.py new file mode 100644 index 000000000..d6e7b9657 --- /dev/null +++ b/src/ims/model/_team.py @@ -0,0 +1,43 @@ +# -*- test-case-name: ranger-ims-server.model.test.test_ranger -*- + +## +# See the file COPYRIGHT for copyright information. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +## + +""" +Team +""" + +from attrs import field, frozen + +from ._replace import ReplaceMixIn + + +__all__ = () + + +@frozen(kw_only=True, order=True) +class Team(ReplaceMixIn): + """ + Team + + A team is a collection of Rangers. + """ + + name: str + members: frozenset[str] = field(order=False) + + def __str__(self) -> str: + return self.name diff --git a/src/ims/model/strategies.py b/src/ims/model/strategies.py index a6b48783a..7e2fcff23 100644 --- a/src/ims/model/strategies.py +++ b/src/ims/model/strategies.py @@ -58,6 +58,7 @@ from ._ranger import Ranger, RangerStatus from ._report import FieldReport from ._state import IncidentState +from ._team import Team from ._type import IncidentType, KnownIncidentType @@ -90,6 +91,7 @@ "rangers", "reportEntries", "rodGarettAddresses", + "teams", "textOnlyAddresses", "timeZones", ) @@ -522,6 +524,20 @@ def positions(draw: Callable[..., Any]) -> Position: ) +## +# Team +## +@composite +def teams(draw: Callable[..., Any]) -> Team: + """ + Strategy that generates :class:`Team` values. + """ + return Team( + name=draw(text(min_size=1)), + members=frozenset(draw(lists(rangerHandles()))), + ) + + ## # Report ## diff --git a/src/ims/model/test/test_team.py b/src/ims/model/test/test_team.py new file mode 100644 index 000000000..a4e1204c4 --- /dev/null +++ b/src/ims/model/test/test_team.py @@ -0,0 +1,42 @@ +## +# See the file COPYRIGHT for copyright information. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +## + +""" +Tests for :mod:`ranger-ims-server.model._team` +""" + +from hypothesis import given + +from ims.ext.trial import TestCase + +from .._team import Team +from ..strategies import teams + + +__all__ = () + + +class RangerTests(TestCase): + """ + Tests for :class:`Ranger` + """ + + @given(teams()) + def test_str(self, team: Team) -> None: + """ + Team renders as a string. + """ + self.assertEqual(str(team), team.name)