From 3ef32c2da6a3bae8577b7950e0557aa358121102 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:30:06 +0100 Subject: [PATCH 01/42] Add BitwardenClient class --- bitwarden2.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 bitwarden2.py diff --git a/bitwarden2.py b/bitwarden2.py new file mode 100644 index 0000000..dbf51fc --- /dev/null +++ b/bitwarden2.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +from __future__ import annotations +from dataclasses import dataclass +from difflib import SequenceMatcher + +import json +import os +import requests +import sys +import tomllib + +from http.client import HTTPSConnection, HTTPResponse +from typing import ( + Any, + Dict, + List, + Generic, + Tuple, + NamedTuple, + Set, + Iterable, + Protocol, + TypeVar, +) + +class BitwardenClient(NamedTuple): + connection: HTTPSConnection + bearer_token: str + + @staticmethod + def new(client_id: str, client_secret: str) -> BitwardenClient: + response = requests.post( + "https://identity.bitwarden.com/connect/token", + headers={ "Content-Type": "application/x-www-form-urlencoded" }, + data={ + "grant_type": "client_credentials", + "scope": "api.organization", + "Accept": "application/json", + }, + auth=(client_id, client_secret), + ) + bearer_token = response.json()["access_token"] + return BitwardenClient(HTTPSConnection("api.bitwarden.com"), bearer_token) + + def _http_get(self, url: str) -> HTTPResponse: + self.connection.request( + method="GET", + url=url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {self.bearer_token}", + }, + ) + + return self.connection.getresponse() \ No newline at end of file From c749769a5d2c119c65f3ce63f6951ac8574a0f40 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:31:10 +0100 Subject: [PATCH 02/42] Add structural classes * Member * Group * Collection * GroupMember * MemberCollectionAccess * GroupCollectionAccess --- bitwarden2.py | 159 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/bitwarden2.py b/bitwarden2.py index dbf51fc..78126e6 100644 --- a/bitwarden2.py +++ b/bitwarden2.py @@ -23,7 +23,163 @@ Protocol, TypeVar, ) +class Member(NamedTuple): + id: str + name: str + email: str + type: int + accessAll: bool + # groups: Tuple[str, ...] + def get_id(self) -> str: + return self.id + + @staticmethod + def from_toml_dict(data: Dict[str, Any]) -> Member: + return Member( + id=data["member_id"], + name=data["member_name"], + email = data["email"], + type = data["type"], + accessAll = data["accessAll"], + ) + + def format_toml(self) -> str: + lines = [ + "[[member]]", + f"member_id = {self.id}", + f"member_name = {self.name}", + f"email = {self.email}", + f"type = {str(self.type)}", + f"accessAll = {str(self.accessAll)}", + ] + return "\n".join(lines) + +class GroupMember(NamedTuple): + member_id: int + member_name: str + group_name: str + + def get_id(self) -> str: + # Our generic differ has the ability to turn add/removes into changes + # for pairs with the same id, but this does not apply to memberships, + # which do not themselves have an id, so the identity to group on is + # the value itself. + return f"{self.member_id}@{self.group_name}" + + def format_toml(self) -> str: + # Needed to satisfy Diffable, but not used in this case. + raise Exception( + "Group memberships are not expressed in toml, " + "please print the diffs in some other way." + ) + +class Group(NamedTuple): + id: str + name: str + accessAll: bool + + def get_id(self) -> str: + return self.id + + @staticmethod + def from_toml_dict(data: Dict[str, Any]) -> Group: + return Group( + id=data["group_id"], + name=data["group_name"], + accessAll = data["accessAll"], + ) + + def format_toml(self) -> str: + lines = [ + "[[group]]", + f"group_id = {self.id}", + f"group_name = {self.name}", + f"accessAll = {str(self.accessAll)}", + ] + return "\n".join(lines) + + +class MemberCollectionAccess(NamedTuple): + id: str + name: str + group: str + + def get_id(self) -> str: + return self.id + + @staticmethod + def from_toml_dict(data: Dict[str, Any]) -> MemberCollectionAccess: + return MemberCollectionAccess( + id=data["member_id"], + name=data["member_name"], + group = data["group"], + ) + + def format_toml(self) -> str: + return ( + "{ member_id = " + + self.id + + ', member_name = "' + + self.name + + '", role = "' + + self.group + + '" }' + ) + +class GroupCollectionAccess(NamedTuple): + id: str + name: str + readOnly: bool + + def get_id(self) -> str: + return self.id + + @staticmethod + def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: + return GroupCollectionAccess( + id=data["group_id"], + name=data["group_name"], + readOnly=data["readOnly"], + ) + + def format_toml(self) -> str: + return ( + "{ group_id = " + + self.id + + ', group_name = "' + + self.name + + '", readOnly = "' + + str(self.readOnly) + + '" }' + ) + +class Collection(NamedTuple): + id: str + externalId: str + group_access: Tuple[GroupCollectionAccess, ...] + member_access: Tuple[MemberCollectionAccess, ...] + + def get_id(self) -> str: + return self.id + + @staticmethod + def from_toml_dict(data: Dict[str, Any]) -> Collection: + return Collection( + id=data["collection_id"], + externalId=data["external_id"], + group_access=tuple( + sorted( + GroupCollectionAccess.from_toml_dict(x) for x in data["group_access"] + ) + ), + member_access=tuple( + sorted( + MemberCollectionAccess.from_toml_dict(x) for x in data["member_access"] + ) + ), + ) + class BitwardenClient(NamedTuple): connection: HTTPSConnection bearer_token: str @@ -53,4 +209,5 @@ def _http_get(self, url: str) -> HTTPResponse: }, ) - return self.connection.getresponse() \ No newline at end of file + return self.connection.getresponse() + From 09d62a1cafdca60c876b9ac8e6f66fdb8dd5a32c Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:35:59 +0100 Subject: [PATCH 03/42] Add BitwardenClient methods to populate objects from API --- bitwarden2.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/bitwarden2.py b/bitwarden2.py index 78126e6..b28c170 100644 --- a/bitwarden2.py +++ b/bitwarden2.py @@ -179,7 +179,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: ) ), ) - + class BitwardenClient(NamedTuple): connection: HTTPSConnection bearer_token: str @@ -211,3 +211,80 @@ def _http_get(self, url: str) -> HTTPResponse: return self.connection.getresponse() + def get_group(self, id: str) -> Any: + return json.load(self._http_get(f"/public/groups/{id}")) + + def get_groups(self) -> Iterable[Group]: + data = self._http_get(f"/public/groups") + groups = json.load(data) + for group in groups["data"]: + yield Group( + id=group["id"], + name=group["name"], + accessAll=group["accessAll"], + ) + + def get_collection_members(self, groups: Tuple[GroupCollectionAccess, ...]) -> Iterable[MemberCollectionAccess]: + for group in groups: + data = self._http_get(f"/public/groups/{group.id}/member-ids") + memberIDs = json.load(data) + + for memberID in memberIDs: + data = self._http_get(f"/public/members/{memberID}") + member = json.load(data) + yield MemberCollectionAccess( + id=member["id"], + name=member["name"], + group=group.id, + ) + + def get_collections(self) -> Iterable[Collection]: + data = self._http_get(f"/public/collections") + collections = json.load(data) + + for collection in collections["data"]: + group_accesses = tuple(sorted(self.get_collection_groups(collection["id"]))) + member_accesses = tuple(sorted(self.get_collection_members(group_accesses))) + + yield Collection( + id=collection["id"], + externalId=collection["externalId"], + member_access=member_accesses, + group_access=group_accesses, + ) + + def get_collection_groups(self, id: str) -> Iterable[GroupCollectionAccess]: + data = self._http_get(f"/public/collections/{id}") + collection = json.load(data) + + for group in collection["groups"]: + yield GroupCollectionAccess( + id=group["id"], + name=self.get_group(group["id"])["name"], + readOnly=group["readOnly"], + ) + + def get_group_members(self, id: str, name: str) -> Iterable[str]: + members = json.load(self._http_get(f"/public/groups/{id}/member-ids")) + + for member in members: + member = json.load(self._http_get(f"/public/members/{member}")) + yield GroupMember( + member_id=member["id"], + member_name=member["name"], + group_name=name, + ) + + def get_members(self) -> Iterable[Member]: + data = self._http_get(f"/public/members") + members= json.load(data) + + for member in members["data"]: + + yield Member( + id=member["id"], + name=member["name"], + email=member["email"], + type=member["type"], + accessAll=member["accessAll"], + ) \ No newline at end of file From 0f54dec72feb0632f5ddf91485309cdb64471ea6 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:36:23 +0100 Subject: [PATCH 04/42] Add Configuration class --- bitwarden2.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/bitwarden2.py b/bitwarden2.py index b28c170..168e279 100644 --- a/bitwarden2.py +++ b/bitwarden2.py @@ -287,4 +287,37 @@ def get_members(self) -> Iterable[Member]: email=member["email"], type=member["type"], accessAll=member["accessAll"], - ) \ No newline at end of file + ) + +class Configuration(NamedTuple): + collection: Set[Collection] + member: Set[Member] + group: Set(Group) + group_memberships: Set[GroupMember] + + @staticmethod + def from_toml_dict(data: Dict[str, Any]) -> Configuration: + collection = {Collection.from_toml_dict(c) for c in data["collection"]} + member = {Member.from_toml_dict(m) for m in data["member"]} + group = {Group.from_toml_dict(m) for m in data["group"]} + group_memberships = { + GroupMember( + member_id=member["member_id"], + member_name=member["member_name"], + group_name=group, + ) + for member in data["member"] + for group in member.get("groups", []) + } + return Configuration( + collection=collection, + member=member, + group=group, + group_memberships=group_memberships, + ) + + @staticmethod + def from_toml_file(fname: str) -> Configuration: + with open(fname, "rb") as f: + data = tomllib.load(f) + return Configuration.from_toml_dict(data) \ No newline at end of file From 41c3359a7b10180c167a7916cb585519dbf23bf2 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:37:09 +0100 Subject: [PATCH 05/42] Copy print diff functions from github-access-manager script --- bitwarden2.py | 161 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/bitwarden2.py b/bitwarden2.py index 168e279..5ac7e72 100644 --- a/bitwarden2.py +++ b/bitwarden2.py @@ -320,4 +320,163 @@ def from_toml_dict(data: Dict[str, Any]) -> Configuration: def from_toml_file(fname: str) -> Configuration: with open(fname, "rb") as f: data = tomllib.load(f) - return Configuration.from_toml_dict(data) \ No newline at end of file + return Configuration.from_toml_dict(data) + +def print_indented(lines: str) -> None: + """Print the input indented by two spaces.""" + for line in lines.splitlines(): + print(f" {line}") + + +def print_simple_diff(actual: str, target: str) -> None: + """ + Print a line-based diff of the two strings, without abbreviating large + chunks of identical lines like a standard unified diff would do. + """ + lines_actual = actual.splitlines() + lines_target = target.splitlines() + line_diff = SequenceMatcher(None, lines_actual, lines_target) + for tag, i1, i2, j1, j2 in line_diff.get_opcodes(): + if tag == "equal": + for line in lines_actual[i1:i2]: + print(" " + line) + elif tag == "replace": + for line in lines_actual[i1:i2]: + print("- " + line) + for line in lines_target[j1:j2]: + print("+ " + line) + elif tag == "delete": + for line in lines_actual[i1:i2]: + print("- " + line) + elif tag == "insert": + for line in lines_target[j1:j2]: + print("+ " + line) + else: + raise Exception("Invalid diff operation.") + + +T = TypeVar("T", bound="Diffable") + + +class Diffable(Protocol): + def __eq__(self: T, other: Any) -> bool: + ... + + def __lt__(self: T, other: T) -> bool: + ... + + def get_id(self: T) -> int | str: + ... + + def format_toml(self: T) -> str: + ... + + +@dataclass(frozen=True) +class DiffEntry(Generic[T]): + actual: T + target: T + + +@dataclass(frozen=True) +class Diff(Generic[T]): + to_add: List[T] + to_remove: List[T] + to_change: List[DiffEntry[T]] + + @staticmethod + def new(target: Set[T], actual: Set[T]) -> Diff[T]: + # A very basic diff is to just look at everything that needs to be added + # and removed, without deeper inspection. + to_add = sorted(target - actual) + to_remove = sorted(actual - target) + + # However, that produces a very rough diff. If we change e.g. the + # description of a group, that would show up as deleting one group and + # adding back another which is almost the same, except with a different + # description. So to improve on this a bit, if entries have ids, and + # the same id needs to be both added and removed, then instead we record + # that as a "change". + to_add_by_id = {x.get_id(): x for x in to_add} + to_remove_by_id = {x.get_id(): x for x in to_remove} + to_change = [ + DiffEntry( + actual=to_remove_by_id[id_], + target=to_add_by_id[id_], + ) + for id_ in sorted(to_add_by_id.keys() & to_remove_by_id.keys()) + ] + + # Now that we turned some add/remove pairs into a "change", we should no + # longer count those as added/removed. + for change in to_change: + to_add.remove(change.target) + to_remove.remove(change.actual) + + return Diff( + to_add=to_add, + to_remove=to_remove, + to_change=to_change, + ) + + def print_diff( + self, + header_to_add: str, + header_to_remove: str, + header_to_change: str, + ) -> None: + if len(self.to_add) > 0: + print(header_to_add) + for entry in self.to_add: + print() + print_indented(entry.format_toml()) + + print() + + if len(self.to_remove) > 0: + print(header_to_remove) + for entry in self.to_remove: + print() + print_indented(entry.format_toml()) + + print() + + if len(self.to_change) > 0: + print(header_to_change) + for change in self.to_change: + print() + print_simple_diff( + actual=change.actual.format_toml(), + target=change.target.format_toml(), + ) + + print() + +def print_group_members_diff( + *, + group_name: str, + target_fname: str, + target_members: Set[GroupMember], + actual_members: Set[GroupMember], +) -> None: + members_diff = Diff.new( + target=target_members, + actual=actual_members, + ) + if len(members_diff.to_remove) > 0: + print( + f"The following members of group '{group_name}' are not specified " + f"in {target_fname}, but are present on Bitwarden:\n" + ) + for member in sorted(members_diff.to_remove): + print(f" {member.member_name}") + print() + + if len(members_diff.to_add) > 0: + print( + f"The following members of group '{group_name}' are specified " + f"in {target_fname}, but are not present on Bitwarden:\n" + ) + for member in sorted(members_diff.to_add): + print(f" {member.member_name}") + print() \ No newline at end of file From 33f3c80ad7cdf19de03583c13f595b15cd87bc7b Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:38:06 +0100 Subject: [PATCH 06/42] Add main --- bitwarden2.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/bitwarden2.py b/bitwarden2.py index 5ac7e72..2d35476 100644 --- a/bitwarden2.py +++ b/bitwarden2.py @@ -479,4 +479,33 @@ def print_group_members_diff( ) for member in sorted(members_diff.to_add): print(f" {member.member_name}") - print() \ No newline at end of file + print() + +def main() -> None: + if "--help" in sys.argv: + print(__doc__) + sys.exit(0) + + client_id = os.getenv("BITWARDEN_CLIENT_ID") + if client_id is None: + print("Expected BITWARDEN_CLIENT_ID environment variable to be set.") + print("See also --help.") + sys.exit(1) + + client_secret = os.getenv("BITWARDEN_CLIENT_SECRET") + if client_secret is None: + print("Expected BITWARDEN_CLIENT_SECRET environment variable to be set.") + print("See also --help.") + sys.exit(1) + + if len(sys.argv) < 2: + print("Expected file name of config toml as first argument.") + print("See also --help.") + sys.exit(1) + + target_fname = sys.argv[1] + target = Configuration.from_toml_file(target_fname) + client = BitwardenClient.new(client_id, client_secret) + +if __name__ == "__main__": + main() \ No newline at end of file From da3cdd60bd2e937be58603ff37a417bc6459039a Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:39:18 +0100 Subject: [PATCH 07/42] Add diff to main --- bitwarden2.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/bitwarden2.py b/bitwarden2.py index 2d35476..524dbfe 100644 --- a/bitwarden2.py +++ b/bitwarden2.py @@ -507,5 +507,45 @@ def main() -> None: target = Configuration.from_toml_file(target_fname) client = BitwardenClient.new(client_id, client_secret) + current_collections = set(client.get_collections()) + collections_diff = Diff.new(target=target.collection, actual=current_collections) + collections_diff.print_diff( + f"The following collections are specified in {target_fname} but not a member of the Bitwarden organization:", + f"The following collections are not specified in {target_fname} but are a member of the Bitwarden organization:", + f"The following collections on Bitwarden need to be changed to match {target_fname}:", + ) + + current_members = set(client.get_members()) + members_diff = Diff.new(target=target.member, actual=current_members) + members_diff.print_diff( + f"The following members are specified in {target_fname} but not a member of the Bitwarden organization:", + f"The following members are not specified in {target_fname} but are a member of the Bitwarden organization:", + f"The following members on Bitwarden need to be changed to match {target_fname}:", + ) + + current_groups = set(client.get_groups()) + groups_diff = Diff.new(target=target.group, actual=current_groups) + groups_diff.print_diff( + f"The following groups specified in {target_fname} are not present on Bitwarden:", + f"The following groups are not specified in {target_fname} but are present on Bitwarden:", + f"The following groups on Bitwarden need to be changed to match {target_fname}:", + ) + + # For all the groups which we want to exist, and which do actually exist, + # compare their members. + target_groups_names = {group.name for group in target.group} + existing_desired_groups = [ + group for group in current_groups if group.name in target_groups_names + ] + for group in existing_desired_groups: + print_group_members_diff( + group_name=group.name, + target_fname=target_fname, + target_members={ + m for m in target.group_memberships if m.group_name == group.name + }, + actual_members=set(client.get_group_members(group.id, group.name)), + ) + if __name__ == "__main__": main() \ No newline at end of file From 24d96dbc228671ea1992b711c8ad769bfee398f4 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:41:08 +0100 Subject: [PATCH 08/42] Rename bitwarden script --- bitwarden2.py => bitwarden_access_manager.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename bitwarden2.py => bitwarden_access_manager.py (100%) diff --git a/bitwarden2.py b/bitwarden_access_manager.py similarity index 100% rename from bitwarden2.py rename to bitwarden_access_manager.py From 3ae6439fe0a72c0335f6aa38ef6ba936b6b39847 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:41:57 +0100 Subject: [PATCH 09/42] Add example configuration in script documentation --- bitwarden_access_manager.py | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 524dbfe..0e44c6d 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -1,5 +1,58 @@ #!/usr/bin/env python3 +""" +Bitwarden Access Manager + +CONFIGURATION + +[[member]] +member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4" +member_name = "yan" +email = "yan.68@hotmail.fr" +type = 2 # member +accessAll = false +groups = ["group1", "group2"] + +[[member]] +member_id = "856cba2d-cae1-40e7-96cc-afaf00a8a4cb" +member_name = "yunkel" +email = "yunkel68@hotmail.fr" +type = 0 # owner +accessAll = true +groups = ["group1"] + +[[group]] +group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" +group_name = "group1" +accessAll = false + +[[group]] +group_id = "39b48ab2-81fd-40eb-87e9-afb0000110f3" +group_name = "group2" +accessAll = false + +[[collection]] +collection_id = "50351c20-55b4-4ee8-bbe0-afaf00a8f25d" +external_id = "collection1" +member_access = [ + { member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4", member_name = "yan", group = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" }, + ] +group_access = [ + { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", readOnly = true }, + { group_id = "39b48ab2-81fd-40eb-87e9-afb0000110f3", group_name = "group2", readOnly = true }, +] + +[[collection]] +collection_id = "8e69ce49-85ae-4e09-a52c-afaf00a90a3f" +external_id = "" +member_access = [ + { member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4", member_name = "yan", group = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" }, +] +group_access = [ + { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", readOnly = false }, +] +""" + from __future__ import annotations from dataclasses import dataclass from difflib import SequenceMatcher From a8de6102a2ec351313e3b7a8463b963ddea01155 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:50:37 +0100 Subject: [PATCH 10/42] Fix type error --- bitwarden_access_manager.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 0e44c6d..737e3ac 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -233,6 +233,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: ), ) + class BitwardenClient(NamedTuple): connection: HTTPSConnection bearer_token: str @@ -317,7 +318,7 @@ def get_collection_groups(self, id: str) -> Iterable[GroupCollectionAccess]: readOnly=group["readOnly"], ) - def get_group_members(self, id: str, name: str) -> Iterable[str]: + def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: members = json.load(self._http_get(f"/public/groups/{id}/member-ids")) for member in members: @@ -345,7 +346,7 @@ def get_members(self) -> Iterable[Member]: class Configuration(NamedTuple): collection: Set[Collection] member: Set[Member] - group: Set(Group) + group: Set[Group] group_memberships: Set[GroupMember] @staticmethod @@ -601,4 +602,4 @@ def main() -> None: ) if __name__ == "__main__": - main() \ No newline at end of file + main() From d9dce1a838243e807ddff77226d22e9981e60b68 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:51:21 +0100 Subject: [PATCH 11/42] Add format_toml missing method to Collection --- bitwarden_access_manager.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 737e3ac..87ab7f6 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -233,6 +233,35 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: ), ) + def format_toml(self) -> str: + member_access_lines = [" " + a.format_toml() for a in sorted(self.member_access)] + group_access_lines = [" " + a.format_toml() for a in sorted(self.group_access)] + result = ( + "[[collection]]\n" + f"collection_id = {self.id}\n" + # Splicing the string is safe here, because Bitwarden repo names are + # very restrictive and do not contain quotes. + f'external_id = "{self.externalId}"\n' + ) + + # For the defaults, you might omit visibility, but when we start + # printing diffs, then we diff against a concrete target, which does + # need to have a visibility. + if len(member_access_lines) > 0: + result = ( + result + "member_access = [\n" + ",\n".join(member_access_lines) + ",\n]\n" + ) + else: + result = result + "member_access = []\n" + + if len(group_access_lines) > 0: + result = ( + result + "group_access = [\n" + ",\n".join(group_access_lines) + ",\n]" + ) + else: + result = result + "group_access = []" + + return result class BitwardenClient(NamedTuple): connection: HTTPSConnection From f33121ce5293b006a64f53bd673ced1d8593d935 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 23 Feb 2023 15:57:10 +0100 Subject: [PATCH 12/42] Rename variable to follow python convention --- bitwarden_access_manager.py | 48 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 87ab7f6..ff9027a 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -10,7 +10,7 @@ member_name = "yan" email = "yan.68@hotmail.fr" type = 2 # member -accessAll = false +access_all = false groups = ["group1", "group2"] [[member]] @@ -18,18 +18,18 @@ member_name = "yunkel" email = "yunkel68@hotmail.fr" type = 0 # owner -accessAll = true +access_all = true groups = ["group1"] [[group]] group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" group_name = "group1" -accessAll = false +access_all = false [[group]] group_id = "39b48ab2-81fd-40eb-87e9-afb0000110f3" group_name = "group2" -accessAll = false +access_all = false [[collection]] collection_id = "50351c20-55b4-4ee8-bbe0-afaf00a8f25d" @@ -38,8 +38,8 @@ { member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4", member_name = "yan", group = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" }, ] group_access = [ - { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", readOnly = true }, - { group_id = "39b48ab2-81fd-40eb-87e9-afb0000110f3", group_name = "group2", readOnly = true }, + { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", read_only = true }, + { group_id = "39b48ab2-81fd-40eb-87e9-afb0000110f3", group_name = "group2", read_only = true }, ] [[collection]] @@ -49,7 +49,7 @@ { member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4", member_name = "yan", group = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" }, ] group_access = [ - { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", readOnly = false }, + { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", read_only = false }, ] """ @@ -81,7 +81,7 @@ class Member(NamedTuple): name: str email: str type: int - accessAll: bool + access_all: bool # groups: Tuple[str, ...] def get_id(self) -> str: @@ -94,7 +94,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Member: name=data["member_name"], email = data["email"], type = data["type"], - accessAll = data["accessAll"], + access_all = data["access_all"], ) def format_toml(self) -> str: @@ -104,7 +104,7 @@ def format_toml(self) -> str: f"member_name = {self.name}", f"email = {self.email}", f"type = {str(self.type)}", - f"accessAll = {str(self.accessAll)}", + f"access_all = {str(self.access_all)}", ] return "\n".join(lines) @@ -130,7 +130,7 @@ def format_toml(self) -> str: class Group(NamedTuple): id: str name: str - accessAll: bool + access_all: bool def get_id(self) -> str: return self.id @@ -140,7 +140,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Group: return Group( id=data["group_id"], name=data["group_name"], - accessAll = data["accessAll"], + access_all = data["access_all"], ) def format_toml(self) -> str: @@ -148,7 +148,7 @@ def format_toml(self) -> str: "[[group]]", f"group_id = {self.id}", f"group_name = {self.name}", - f"accessAll = {str(self.accessAll)}", + f"access_all = {str(self.access_all)}", ] return "\n".join(lines) @@ -183,7 +183,7 @@ def format_toml(self) -> str: class GroupCollectionAccess(NamedTuple): id: str name: str - readOnly: bool + read_only: bool def get_id(self) -> str: return self.id @@ -193,7 +193,7 @@ def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: return GroupCollectionAccess( id=data["group_id"], name=data["group_name"], - readOnly=data["readOnly"], + read_only=data["read_only"], ) def format_toml(self) -> str: @@ -202,14 +202,14 @@ def format_toml(self) -> str: + self.id + ', group_name = "' + self.name - + '", readOnly = "' - + str(self.readOnly) + + '", read_only = "' + + str(self.read_only) + '" }' ) class Collection(NamedTuple): id: str - externalId: str + external_id: str group_access: Tuple[GroupCollectionAccess, ...] member_access: Tuple[MemberCollectionAccess, ...] @@ -220,7 +220,7 @@ def get_id(self) -> str: def from_toml_dict(data: Dict[str, Any]) -> Collection: return Collection( id=data["collection_id"], - externalId=data["external_id"], + external_id=data["external_id"], group_access=tuple( sorted( GroupCollectionAccess.from_toml_dict(x) for x in data["group_access"] @@ -241,7 +241,7 @@ def format_toml(self) -> str: f"collection_id = {self.id}\n" # Splicing the string is safe here, because Bitwarden repo names are # very restrictive and do not contain quotes. - f'external_id = "{self.externalId}"\n' + f'external_id = "{self.external_id}"\n' ) # For the defaults, you might omit visibility, but when we start @@ -304,7 +304,7 @@ def get_groups(self) -> Iterable[Group]: yield Group( id=group["id"], name=group["name"], - accessAll=group["accessAll"], + access_all=group["accessAll"], ) def get_collection_members(self, groups: Tuple[GroupCollectionAccess, ...]) -> Iterable[MemberCollectionAccess]: @@ -331,7 +331,7 @@ def get_collections(self) -> Iterable[Collection]: yield Collection( id=collection["id"], - externalId=collection["externalId"], + external_id=collection["externalId"], member_access=member_accesses, group_access=group_accesses, ) @@ -344,7 +344,7 @@ def get_collection_groups(self, id: str) -> Iterable[GroupCollectionAccess]: yield GroupCollectionAccess( id=group["id"], name=self.get_group(group["id"])["name"], - readOnly=group["readOnly"], + read_only=group["readOnly"], ) def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: @@ -369,7 +369,7 @@ def get_members(self) -> Iterable[Member]: name=member["name"], email=member["email"], type=member["type"], - accessAll=member["accessAll"], + access_all=member["accessAll"], ) class Configuration(NamedTuple): From 7913d5aa7c04ec73508725a99065becd49099d4b Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Feb 2023 08:54:15 +0100 Subject: [PATCH 13/42] User friendly member type and optional access_all member key --- bitwarden_access_manager.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index ff9027a..b8b0cd5 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -69,6 +69,7 @@ Dict, List, Generic, + Optional, Tuple, NamedTuple, Set, @@ -80,8 +81,8 @@ class Member(NamedTuple): id: str name: str email: str - type: int - access_all: bool + type: str + access_all: Optional[bool] # groups: Tuple[str, ...] def get_id(self) -> str: @@ -89,12 +90,15 @@ def get_id(self) -> str: @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Member: + access_all: Optional[bool] = False + if "access_all" in data: + access_all = data["access_all"] return Member( id=data["member_id"], name=data["member_name"], email = data["email"], type = data["type"], - access_all = data["access_all"], + access_all = access_all, ) def format_toml(self) -> str: @@ -104,7 +108,7 @@ def format_toml(self) -> str: f"member_name = {self.name}", f"email = {self.email}", f"type = {str(self.type)}", - f"access_all = {str(self.access_all)}", + f"access_all = {str(self.access_all).lower()}", ] return "\n".join(lines) @@ -358,17 +362,33 @@ def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: group_name=name, ) + def set_member_type(self, type_id: int) -> str: + match type_id: + case 0: + type="owner" + case 1: + type="admin" + case 2: + type="user" + case 3: + type="manager" + case 4: + type="custom" + return type + def get_members(self) -> Iterable[Member]: data = self._http_get(f"/public/members") members= json.load(data) for member in members["data"]: + type=self.set_member_type(member["type"]) + yield Member( id=member["id"], name=member["name"], email=member["email"], - type=member["type"], + type=type, access_all=member["accessAll"], ) From e8827ece4e087af260cde0af776df99e8f73df9e Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Feb 2023 10:21:09 +0100 Subject: [PATCH 14/42] Optional group_access and member_access keys for collection --- bitwarden_access_manager.py | 75 ++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index b8b0cd5..da1d69c 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -214,32 +214,38 @@ def format_toml(self) -> str: class Collection(NamedTuple): id: str external_id: str - group_access: Tuple[GroupCollectionAccess, ...] - member_access: Tuple[MemberCollectionAccess, ...] + group_access: Optional[Tuple[GroupCollectionAccess, ...]] + member_access: Optional[Tuple[MemberCollectionAccess, ...]] def get_id(self) -> str: return self.id @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Collection: - return Collection( - id=data["collection_id"], - external_id=data["external_id"], - group_access=tuple( + group_access: Optional[Tuple[GroupCollectionAccess, ...]] = None + if "group_access" in data: + group_access = tuple( sorted( GroupCollectionAccess.from_toml_dict(x) for x in data["group_access"] ) - ), - member_access=tuple( + ) + + member_access: Optional[Tuple[MemberCollectionAccess, ...]] = None + if "member_access" in data: + member_access = tuple( sorted( MemberCollectionAccess.from_toml_dict(x) for x in data["member_access"] ) - ), + ) + + return Collection( + id=data["collection_id"], + external_id=data["external_id"], + group_access=group_access, + member_access=member_access, ) def format_toml(self) -> str: - member_access_lines = [" " + a.format_toml() for a in sorted(self.member_access)] - group_access_lines = [" " + a.format_toml() for a in sorted(self.group_access)] result = ( "[[collection]]\n" f"collection_id = {self.id}\n" @@ -248,23 +254,24 @@ def format_toml(self) -> str: f'external_id = "{self.external_id}"\n' ) - # For the defaults, you might omit visibility, but when we start - # printing diffs, then we diff against a concrete target, which does - # need to have a visibility. - if len(member_access_lines) > 0: - result = ( - result + "member_access = [\n" + ",\n".join(member_access_lines) + ",\n]\n" - ) - else: - result = result + "member_access = []\n" - - if len(group_access_lines) > 0: - result = ( - result + "group_access = [\n" + ",\n".join(group_access_lines) + ",\n]" - ) - else: - result = result + "group_access = []" - + if self.member_access is not None: + member_access_lines = [" " + a.format_toml() for a in sorted(self.member_access)] + if len(member_access_lines) > 0: + result = ( + result + "member_access = [\n" + ",\n".join(member_access_lines) + ",\n]\n" + ) + else: + result = result + "member_access = []\n" + + if self.group_access is not None: + group_access_lines = [" " + a.format_toml() for a in sorted(self.group_access)] + if len(group_access_lines) > 0: + result = ( + result + "group_access = [\n" + ",\n".join(group_access_lines) + ",\n]" + ) + else: + result = result + "group_access = []" + print(result) return result class BitwardenClient(NamedTuple): @@ -330,8 +337,16 @@ def get_collections(self) -> Iterable[Collection]: collections = json.load(data) for collection in collections["data"]: - group_accesses = tuple(sorted(self.get_collection_groups(collection["id"]))) - member_accesses = tuple(sorted(self.get_collection_members(group_accesses))) + group_accesses: Optional[Tuple[GroupCollectionAccess, ...]] = None + member_accesses: Optional[Tuple[MemberCollectionAccess, ...]] = None + + group_accesses_data = tuple(sorted(self.get_collection_groups(collection["id"]))) + + if group_accesses_data: + group_accesses = group_accesses_data + member_accesses_data = tuple(sorted(self.get_collection_members(group_accesses_data))) + if member_accesses_data: + member_accesses = member_accesses_data yield Collection( id=collection["id"], From 511286a69eee040c4b464d6bb9683ba9d21b874d Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Feb 2023 11:15:02 +0100 Subject: [PATCH 15/42] Change read_only parameter for readable access parameter --- bitwarden_access_manager.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index da1d69c..a2fd0b9 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -187,7 +187,7 @@ def format_toml(self) -> str: class GroupCollectionAccess(NamedTuple): id: str name: str - read_only: bool + access: str def get_id(self) -> str: return self.id @@ -197,7 +197,7 @@ def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: return GroupCollectionAccess( id=data["group_id"], name=data["group_name"], - read_only=data["read_only"], + access=data["access"], ) def format_toml(self) -> str: @@ -206,8 +206,8 @@ def format_toml(self) -> str: + self.id + ', group_name = "' + self.name - + '", read_only = "' - + str(self.read_only) + + '", access = "' + + self.access + '" }' ) @@ -271,7 +271,6 @@ def format_toml(self) -> str: ) else: result = result + "group_access = []" - print(result) return result class BitwardenClient(NamedTuple): @@ -360,10 +359,15 @@ def get_collection_groups(self, id: str) -> Iterable[GroupCollectionAccess]: collection = json.load(data) for group in collection["groups"]: + if group["readOnly"] == True: + access = "readonly" + else: + access = "write" + yield GroupCollectionAccess( id=group["id"], name=self.get_group(group["id"])["name"], - read_only=group["readOnly"], + access=access, ) def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: From 9316ca8d18b1ae4c346bab9ac096bdc9316a9bf6 Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Feb 2023 11:17:05 +0100 Subject: [PATCH 16/42] Optional access_all group key --- bitwarden_access_manager.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index a2fd0b9..0e676fb 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -134,17 +134,21 @@ def format_toml(self) -> str: class Group(NamedTuple): id: str name: str - access_all: bool + access_all: Optional[bool] def get_id(self) -> str: return self.id @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Group: + access_all: Optional[bool] = False + if "access_all" in data: + access_all = data["access_all"] + return Group( id=data["group_id"], name=data["group_name"], - access_all = data["access_all"], + access_all = access_all, ) def format_toml(self) -> str: @@ -152,7 +156,7 @@ def format_toml(self) -> str: "[[group]]", f"group_id = {self.id}", f"group_name = {self.name}", - f"access_all = {str(self.access_all)}", + f"access_all = {str(self.access_all).lower()}", ] return "\n".join(lines) From 166f7aa9382104d45825895d08c8e424bff24056 Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Feb 2023 12:30:53 +0100 Subject: [PATCH 17/42] Remove redundant ids in collection group_access and member_access --- bitwarden_access_manager.py | 77 +++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 0e676fb..699c5f5 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -162,9 +162,7 @@ def format_toml(self) -> str: class MemberCollectionAccess(NamedTuple): - id: str name: str - group: str def get_id(self) -> str: return self.id @@ -172,24 +170,17 @@ def get_id(self) -> str: @staticmethod def from_toml_dict(data: Dict[str, Any]) -> MemberCollectionAccess: return MemberCollectionAccess( - id=data["member_id"], name=data["member_name"], - group = data["group"], ) def format_toml(self) -> str: return ( - "{ member_id = " - + self.id - + ', member_name = "' + "{ member_name = " + self.name - + '", role = "' - + self.group - + '" }' + + '"}' ) class GroupCollectionAccess(NamedTuple): - id: str name: str access: str @@ -199,16 +190,13 @@ def get_id(self) -> str: @staticmethod def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: return GroupCollectionAccess( - id=data["group_id"], name=data["group_name"], access=data["access"], ) def format_toml(self) -> str: return ( - "{ group_id = " - + self.id - + ', group_name = "' + "{ group_name = " + self.name + '", access = "' + self.access @@ -321,20 +309,43 @@ def get_groups(self) -> Iterable[Group]: access_all=group["accessAll"], ) - def get_collection_members(self, groups: Tuple[GroupCollectionAccess, ...]) -> Iterable[MemberCollectionAccess]: + def get_collection_members(self, groups: Any) -> Iterable[MemberCollectionAccess]: for group in groups: - data = self._http_get(f"/public/groups/{group.id}/member-ids") - memberIDs = json.load(data) + group_id = group["id"] - for memberID in memberIDs: - data = self._http_get(f"/public/members/{memberID}") + data = self._http_get(f"/public/groups/{group_id}/member-ids") + member_ids = json.load(data) + + for member_id in member_ids: + data = self._http_get(f"/public/members/{member_id}") member = json.load(data) yield MemberCollectionAccess( - id=member["id"], name=member["name"], - group=group.id, ) + def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: + + for group in groups: + if group["readOnly"] == True: + access = "readonly" + else: + access = "write" + + yield GroupCollectionAccess( + name=self.get_group(group["id"])["name"], + access=access, + ) + + def get_member_collections(self, members: Tuple[MemberCollectionAccess, ...]): + print("JERE") + for member in members: + data = self._http_get(f"/public/members/{member.id}") + member = json.load(data) + print(member) + + def get_collection(self, id: str) -> Any: + return json.load(self._http_get(f"/public/collections/{id}")) + def get_collections(self) -> Iterable[Collection]: data = self._http_get(f"/public/collections") collections = json.load(data) @@ -342,12 +353,13 @@ def get_collections(self) -> Iterable[Collection]: for collection in collections["data"]: group_accesses: Optional[Tuple[GroupCollectionAccess, ...]] = None member_accesses: Optional[Tuple[MemberCollectionAccess, ...]] = None + collection_data = self.get_collection(collection["id"]) - group_accesses_data = tuple(sorted(self.get_collection_groups(collection["id"]))) + group_accesses_data = tuple(sorted(self.get_collection_groups(collection_data["groups"]))) if group_accesses_data: group_accesses = group_accesses_data - member_accesses_data = tuple(sorted(self.get_collection_members(group_accesses_data))) + member_accesses_data = tuple(sorted(self.get_collection_members(collection_data["groups"]))) if member_accesses_data: member_accesses = member_accesses_data @@ -358,22 +370,6 @@ def get_collections(self) -> Iterable[Collection]: group_access=group_accesses, ) - def get_collection_groups(self, id: str) -> Iterable[GroupCollectionAccess]: - data = self._http_get(f"/public/collections/{id}") - collection = json.load(data) - - for group in collection["groups"]: - if group["readOnly"] == True: - access = "readonly" - else: - access = "write" - - yield GroupCollectionAccess( - id=group["id"], - name=self.get_group(group["id"])["name"], - access=access, - ) - def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: members = json.load(self._http_get(f"/public/groups/{id}/member-ids")) @@ -404,7 +400,6 @@ def get_members(self) -> Iterable[Member]: members= json.load(data) for member in members["data"]: - type=self.set_member_type(member["type"]) yield Member( From 63296ca67e73d16260b0142159d1cf51a7ea2da5 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 2 Mar 2023 08:50:19 +0100 Subject: [PATCH 18/42] Remove optional for access_all --- bitwarden_access_manager.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 699c5f5..4bfc099 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -82,7 +82,7 @@ class Member(NamedTuple): name: str email: str type: str - access_all: Optional[bool] + access_all: bool # groups: Tuple[str, ...] def get_id(self) -> str: @@ -90,7 +90,7 @@ def get_id(self) -> str: @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Member: - access_all: Optional[bool] = False + access_all: bool = False if "access_all" in data: access_all = data["access_all"] return Member( @@ -134,14 +134,14 @@ def format_toml(self) -> str: class Group(NamedTuple): id: str name: str - access_all: Optional[bool] + access_all: bool def get_id(self) -> str: return self.id @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Group: - access_all: Optional[bool] = False + access_all: bool = False if "access_all" in data: access_all = data["access_all"] @@ -337,7 +337,6 @@ def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: ) def get_member_collections(self, members: Tuple[MemberCollectionAccess, ...]): - print("JERE") for member in members: data = self._http_get(f"/public/members/{member.id}") member = json.load(data) From 1d26b3a1aecea6d503dda114152e049d3d297e48 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 2 Mar 2023 11:36:30 +0100 Subject: [PATCH 19/42] Apply black formatting --- bitwarden_access_manager.py | 123 ++++++++++++++++++++---------------- 1 file changed, 70 insertions(+), 53 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 4bfc099..a6e2240 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -77,6 +77,8 @@ Protocol, TypeVar, ) + + class Member(NamedTuple): id: str name: str @@ -96,9 +98,9 @@ def from_toml_dict(data: Dict[str, Any]) -> Member: return Member( id=data["member_id"], name=data["member_name"], - email = data["email"], - type = data["type"], - access_all = access_all, + email=data["email"], + type=data["type"], + access_all=access_all, ) def format_toml(self) -> str: @@ -112,6 +114,7 @@ def format_toml(self) -> str: ] return "\n".join(lines) + class GroupMember(NamedTuple): member_id: int member_name: str @@ -131,6 +134,7 @@ def format_toml(self) -> str: "please print the diffs in some other way." ) + class Group(NamedTuple): id: str name: str @@ -148,7 +152,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Group: return Group( id=data["group_id"], name=data["group_name"], - access_all = access_all, + access_all=access_all, ) def format_toml(self) -> str: @@ -174,11 +178,8 @@ def from_toml_dict(data: Dict[str, Any]) -> MemberCollectionAccess: ) def format_toml(self) -> str: - return ( - "{ member_name = " - + self.name - + '"}' - ) + return "{ member_name = " + self.name + '"}' + class GroupCollectionAccess(NamedTuple): name: str @@ -195,13 +196,8 @@ def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: ) def format_toml(self) -> str: - return ( - "{ group_name = " - + self.name - + '", access = "' - + self.access - + '" }' - ) + return "{ group_name = " + self.name + '", access = "' + self.access + '" }' + class Collection(NamedTuple): id: str @@ -218,7 +214,8 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: if "group_access" in data: group_access = tuple( sorted( - GroupCollectionAccess.from_toml_dict(x) for x in data["group_access"] + GroupCollectionAccess.from_toml_dict(x) + for x in data["group_access"] ) ) @@ -226,7 +223,8 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: if "member_access" in data: member_access = tuple( sorted( - MemberCollectionAccess.from_toml_dict(x) for x in data["member_access"] + MemberCollectionAccess.from_toml_dict(x) + for x in data["member_access"] ) ) @@ -235,7 +233,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: external_id=data["external_id"], group_access=group_access, member_access=member_access, - ) + ) def format_toml(self) -> str: result = ( @@ -247,24 +245,35 @@ def format_toml(self) -> str: ) if self.member_access is not None: - member_access_lines = [" " + a.format_toml() for a in sorted(self.member_access)] + member_access_lines = [ + " " + a.format_toml() for a in sorted(self.member_access) + ] if len(member_access_lines) > 0: result = ( - result + "member_access = [\n" + ",\n".join(member_access_lines) + ",\n]\n" + result + + "member_access = [\n" + + ",\n".join(member_access_lines) + + ",\n]\n" ) else: result = result + "member_access = []\n" if self.group_access is not None: - group_access_lines = [" " + a.format_toml() for a in sorted(self.group_access)] + group_access_lines = [ + " " + a.format_toml() for a in sorted(self.group_access) + ] if len(group_access_lines) > 0: result = ( - result + "group_access = [\n" + ",\n".join(group_access_lines) + ",\n]" + result + + "group_access = [\n" + + ",\n".join(group_access_lines) + + ",\n]" ) else: result = result + "group_access = []" return result + class BitwardenClient(NamedTuple): connection: HTTPSConnection bearer_token: str @@ -273,7 +282,7 @@ class BitwardenClient(NamedTuple): def new(client_id: str, client_secret: str) -> BitwardenClient: response = requests.post( "https://identity.bitwarden.com/connect/token", - headers={ "Content-Type": "application/x-www-form-urlencoded" }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ "grant_type": "client_credentials", "scope": "api.organization", @@ -310,31 +319,30 @@ def get_groups(self) -> Iterable[Group]: ) def get_collection_members(self, groups: Any) -> Iterable[MemberCollectionAccess]: - for group in groups: - group_id = group["id"] + for group in groups: + group_id = group["id"] - data = self._http_get(f"/public/groups/{group_id}/member-ids") - member_ids = json.load(data) + data = self._http_get(f"/public/groups/{group_id}/member-ids") + member_ids = json.load(data) - for member_id in member_ids: - data = self._http_get(f"/public/members/{member_id}") - member = json.load(data) - yield MemberCollectionAccess( - name=member["name"], - ) + for member_id in member_ids: + data = self._http_get(f"/public/members/{member_id}") + member = json.load(data) + yield MemberCollectionAccess( + name=member["name"], + ) def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: + for group in groups: + if group["readOnly"] == True: + access = "readonly" + else: + access = "write" - for group in groups: - if group["readOnly"] == True: - access = "readonly" - else: - access = "write" - - yield GroupCollectionAccess( - name=self.get_group(group["id"])["name"], - access=access, - ) + yield GroupCollectionAccess( + name=self.get_group(group["id"])["name"], + access=access, + ) def get_member_collections(self, members: Tuple[MemberCollectionAccess, ...]): for member in members: @@ -354,11 +362,15 @@ def get_collections(self) -> Iterable[Collection]: member_accesses: Optional[Tuple[MemberCollectionAccess, ...]] = None collection_data = self.get_collection(collection["id"]) - group_accesses_data = tuple(sorted(self.get_collection_groups(collection_data["groups"]))) + group_accesses_data = tuple( + sorted(self.get_collection_groups(collection_data["groups"])) + ) if group_accesses_data: group_accesses = group_accesses_data - member_accesses_data = tuple(sorted(self.get_collection_members(collection_data["groups"]))) + member_accesses_data = tuple( + sorted(self.get_collection_members(collection_data["groups"])) + ) if member_accesses_data: member_accesses = member_accesses_data @@ -383,23 +395,23 @@ def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: def set_member_type(self, type_id: int) -> str: match type_id: case 0: - type="owner" + type = "owner" case 1: - type="admin" + type = "admin" case 2: - type="user" + type = "user" case 3: - type="manager" + type = "manager" case 4: - type="custom" + type = "custom" return type def get_members(self) -> Iterable[Member]: data = self._http_get(f"/public/members") - members= json.load(data) + members = json.load(data) for member in members["data"]: - type=self.set_member_type(member["type"]) + type = self.set_member_type(member["type"]) yield Member( id=member["id"], @@ -409,6 +421,7 @@ def get_members(self) -> Iterable[Member]: access_all=member["accessAll"], ) + class Configuration(NamedTuple): collection: Set[Collection] member: Set[Member] @@ -442,6 +455,7 @@ def from_toml_file(fname: str) -> Configuration: data = tomllib.load(f) return Configuration.from_toml_dict(data) + def print_indented(lines: str) -> None: """Print the input indented by two spaces.""" for line in lines.splitlines(): @@ -572,6 +586,7 @@ def print_diff( print() + def print_group_members_diff( *, group_name: str, @@ -601,6 +616,7 @@ def print_group_members_diff( print(f" {member.member_name}") print() + def main() -> None: if "--help" in sys.argv: print(__doc__) @@ -667,5 +683,6 @@ def main() -> None: actual_members=set(client.get_group_members(group.id, group.name)), ) + if __name__ == "__main__": main() From 7250ebf24bb9894fcd2f2d262ddd92ad7c43b20f Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 2 Mar 2023 12:26:15 +0100 Subject: [PATCH 20/42] Remove comment about collection external_id splicing --- bitwarden_access_manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index a6e2240..785f0fb 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -239,8 +239,6 @@ def format_toml(self) -> str: result = ( "[[collection]]\n" f"collection_id = {self.id}\n" - # Splicing the string is safe here, because Bitwarden repo names are - # very restrictive and do not contain quotes. f'external_id = "{self.external_id}"\n' ) From c23a41c8ebda0c47cfb9c7584224d287c8c8c9e1 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 2 Mar 2023 12:46:01 +0100 Subject: [PATCH 21/42] Remove redundant and not used BitwardenClient methods --- bitwarden_access_manager.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 785f0fb..1f91efb 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -303,12 +303,8 @@ def _http_get(self, url: str) -> HTTPResponse: return self.connection.getresponse() - def get_group(self, id: str) -> Any: - return json.load(self._http_get(f"/public/groups/{id}")) - def get_groups(self) -> Iterable[Group]: - data = self._http_get(f"/public/groups") - groups = json.load(data) + groups = json.load(self._http_get(f"/public/groups")) for group in groups["data"]: yield Group( id=group["id"], @@ -320,12 +316,10 @@ def get_collection_members(self, groups: Any) -> Iterable[MemberCollectionAccess for group in groups: group_id = group["id"] - data = self._http_get(f"/public/groups/{group_id}/member-ids") - member_ids = json.load(data) + member_ids = json.load(self._http_get(f"/public/groups/{group_id}/member-ids")) for member_id in member_ids: - data = self._http_get(f"/public/members/{member_id}") - member = json.load(data) + member = json.load(self._http_get(f"/public/members/{member_id}")) yield MemberCollectionAccess( name=member["name"], ) @@ -337,28 +331,25 @@ def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: else: access = "write" + group_id = group["id"] yield GroupCollectionAccess( - name=self.get_group(group["id"])["name"], + name=json.load(self._http_get(f"/public/groups/{group_id}"))["name"], access=access, ) def get_member_collections(self, members: Tuple[MemberCollectionAccess, ...]): for member in members: - data = self._http_get(f"/public/members/{member.id}") - member = json.load(data) - print(member) - - def get_collection(self, id: str) -> Any: - return json.load(self._http_get(f"/public/collections/{id}")) + member = json.load(self._http_get(f"/public/members/{member.id}")) def get_collections(self) -> Iterable[Collection]: - data = self._http_get(f"/public/collections") - collections = json.load(data) + collections = json.load(self._http_get(f"/public/collections")) for collection in collections["data"]: group_accesses: Optional[Tuple[GroupCollectionAccess, ...]] = None member_accesses: Optional[Tuple[MemberCollectionAccess, ...]] = None - collection_data = self.get_collection(collection["id"]) + collection_id = collection["id"] + + collection_data = json.load(self._http_get(f"/public/collections/{collection_id}")) group_accesses_data = tuple( sorted(self.get_collection_groups(collection_data["groups"])) From ebf5372ed8358b3de41f217494492f1d59f7139c Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 2 Mar 2023 13:21:24 +0100 Subject: [PATCH 22/42] Change implicit to explicit boolean conversion --- bitwarden_access_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 1f91efb..38e36b0 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -355,12 +355,12 @@ def get_collections(self) -> Iterable[Collection]: sorted(self.get_collection_groups(collection_data["groups"])) ) - if group_accesses_data: + if len(group_accesses_data) > 0: group_accesses = group_accesses_data member_accesses_data = tuple( sorted(self.get_collection_members(collection_data["groups"])) ) - if member_accesses_data: + if len(member_accesses_data) > 0: member_accesses = member_accesses_data yield Collection( From 6fd0242869e02e934f6ec59fdb9c29a25f66e4ce Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 2 Mar 2023 13:26:27 +0100 Subject: [PATCH 23/42] Fix mypy errors --- bitwarden_access_manager.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 38e36b0..7ed2f17 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -168,9 +168,6 @@ def format_toml(self) -> str: class MemberCollectionAccess(NamedTuple): name: str - def get_id(self) -> str: - return self.id - @staticmethod def from_toml_dict(data: Dict[str, Any]) -> MemberCollectionAccess: return MemberCollectionAccess( @@ -185,9 +182,6 @@ class GroupCollectionAccess(NamedTuple): name: str access: str - def get_id(self) -> str: - return self.id - @staticmethod def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: return GroupCollectionAccess( @@ -337,10 +331,6 @@ def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: access=access, ) - def get_member_collections(self, members: Tuple[MemberCollectionAccess, ...]): - for member in members: - member = json.load(self._http_get(f"/public/members/{member.id}")) - def get_collections(self) -> Iterable[Collection]: collections = json.load(self._http_get(f"/public/collections")) From 7c6e97f79bd89c136651163a03b1eb02b293e5b1 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 2 Mar 2023 13:41:43 +0100 Subject: [PATCH 24/42] Update docstring --- bitwarden_access_manager.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 7ed2f17..1de49e6 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -9,16 +9,15 @@ member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4" member_name = "yan" email = "yan.68@hotmail.fr" -type = 2 # member -access_all = false +type = "member" groups = ["group1", "group2"] [[member]] member_id = "856cba2d-cae1-40e7-96cc-afaf00a8a4cb" member_name = "yunkel" email = "yunkel68@hotmail.fr" -type = 0 # owner -access_all = true +type = "owner" +access_all = true # access_all is optional, default is false groups = ["group1"] [[group]] From 378c65382158f56ebe99c792158df5ceb7fcb288 Mon Sep 17 00:00:00 2001 From: yannick Date: Fri, 3 Mar 2023 09:50:54 +0100 Subject: [PATCH 25/42] Use members hash in get_collection_members --- bitwarden_access_manager.py | 44 +++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 1de49e6..fbee6b8 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -55,6 +55,8 @@ from __future__ import annotations from dataclasses import dataclass from difflib import SequenceMatcher +from enum import Enum +from http.client import HTTPSConnection, HTTPResponse import json import os @@ -62,7 +64,6 @@ import sys import tomllib -from http.client import HTTPSConnection, HTTPResponse from typing import ( Any, Dict, @@ -305,16 +306,19 @@ def get_groups(self) -> Iterable[Group]: access_all=group["accessAll"], ) - def get_collection_members(self, groups: Any) -> Iterable[MemberCollectionAccess]: + def get_collection_members( + self, groups: Any, org_members: Dict[str, Member] + ) -> Iterable[MemberCollectionAccess]: for group in groups: group_id = group["id"] - member_ids = json.load(self._http_get(f"/public/groups/{group_id}/member-ids")) + member_ids = json.load( + self._http_get(f"/public/groups/{group_id}/member-ids") + ) for member_id in member_ids: - member = json.load(self._http_get(f"/public/members/{member_id}")) yield MemberCollectionAccess( - name=member["name"], + name=org_members[member_id].name, ) def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: @@ -330,7 +334,7 @@ def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: access=access, ) - def get_collections(self) -> Iterable[Collection]: + def get_collections(self, org_members: Dict[str, Member]) -> Iterable[Collection]: collections = json.load(self._http_get(f"/public/collections")) for collection in collections["data"]: @@ -338,7 +342,9 @@ def get_collections(self) -> Iterable[Collection]: member_accesses: Optional[Tuple[MemberCollectionAccess, ...]] = None collection_id = collection["id"] - collection_data = json.load(self._http_get(f"/public/collections/{collection_id}")) + collection_data = json.load( + self._http_get(f"/public/collections/{collection_id}") + ) group_accesses_data = tuple( sorted(self.get_collection_groups(collection_data["groups"])) @@ -347,8 +353,13 @@ def get_collections(self) -> Iterable[Collection]: if len(group_accesses_data) > 0: group_accesses = group_accesses_data member_accesses_data = tuple( - sorted(self.get_collection_members(collection_data["groups"])) + sorted( + self.get_collection_members( + groups=collection_data["groups"], org_members=org_members + ) + ) ) + if len(member_accesses_data) > 0: member_accesses = member_accesses_data @@ -621,14 +632,6 @@ def main() -> None: target = Configuration.from_toml_file(target_fname) client = BitwardenClient.new(client_id, client_secret) - current_collections = set(client.get_collections()) - collections_diff = Diff.new(target=target.collection, actual=current_collections) - collections_diff.print_diff( - f"The following collections are specified in {target_fname} but not a member of the Bitwarden organization:", - f"The following collections are not specified in {target_fname} but are a member of the Bitwarden organization:", - f"The following collections on Bitwarden need to be changed to match {target_fname}:", - ) - current_members = set(client.get_members()) members_diff = Diff.new(target=target.member, actual=current_members) members_diff.print_diff( @@ -637,6 +640,15 @@ def main() -> None: f"The following members on Bitwarden need to be changed to match {target_fname}:", ) + org_members: Dict[str, Member] = {member.id: member for member in current_members} + current_collections = set(client.get_collections(org_members)) + collections_diff = Diff.new(target=target.collection, actual=current_collections) + collections_diff.print_diff( + f"The following collections are specified in {target_fname} but not a member of the Bitwarden organization:", + f"The following collections are not specified in {target_fname} but are a member of the Bitwarden organization:", + f"The following collections on Bitwarden need to be changed to match {target_fname}:", + ) + current_groups = set(client.get_groups()) groups_diff = Diff.new(target=target.group, actual=current_groups) groups_diff.print_diff( From cea3fc0dc92c477892ea5bf67cd1dccaa52e6a6f Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 16 Mar 2023 13:38:55 +0100 Subject: [PATCH 26/42] Add MemberType class --- bitwarden_access_manager.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index fbee6b8..6dacddb 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -78,12 +78,18 @@ TypeVar, ) +class MemberType(Enum): + OWNER = 0 + ADMIN = 1 + USER = 2 + MANAGER = 3 + CUSTOM = 4 class Member(NamedTuple): id: str name: str email: str - type: str + type: MemberType access_all: bool # groups: Tuple[str, ...] @@ -99,7 +105,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Member: id=data["member_id"], name=data["member_name"], email=data["email"], - type=data["type"], + type=MemberType[data["type"].upper()], access_all=access_all, ) @@ -233,7 +239,7 @@ def format_toml(self) -> str: result = ( "[[collection]]\n" f"collection_id = {self.id}\n" - f'external_id = "{self.external_id}"\n' + f"external_id = {self.external_id}\n" ) if self.member_access is not None: @@ -381,19 +387,16 @@ def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: group_name=name, ) - def set_member_type(self, type_id: int) -> str: - match type_id: - case 0: - type = "owner" - case 1: - type = "admin" - case 2: - type = "user" - case 3: - type = "manager" - case 4: - type = "custom" - return type + def set_member_type(self, type_id: int) -> MemberType: + int_to_member_type: Dict[int, MemberType] = { + 0: MemberType.OWNER, + 1: MemberType.ADMIN, + 2: MemberType.USER, + 3: MemberType.MANAGER, + 4: MemberType.CUSTOM, + } + + return MemberType(int_to_member_type[type_id]) def get_members(self) -> Iterable[Member]: data = self._http_get(f"/public/members") From d8013aa4cd1cccbea166a1cda7c76664732efb2d Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 16 Mar 2023 13:40:19 +0100 Subject: [PATCH 27/42] get_member_collection types --- bitwarden_access_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 6dacddb..1c34327 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -78,6 +78,7 @@ TypeVar, ) + class MemberType(Enum): OWNER = 0 ADMIN = 1 @@ -85,6 +86,7 @@ class MemberType(Enum): MANAGER = 3 CUSTOM = 4 + class Member(NamedTuple): id: str name: str @@ -313,7 +315,7 @@ def get_groups(self) -> Iterable[Group]: ) def get_collection_members( - self, groups: Any, org_members: Dict[str, Member] + self, groups: List[Dict[str, Any]], org_members: Dict[str, Member] ) -> Iterable[MemberCollectionAccess]: for group in groups: group_id = group["id"] From 1168dc0923ac8d4a024592c38faf0ec30635ca68 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 16 Mar 2023 13:56:03 +0100 Subject: [PATCH 28/42] Add GroupAccess enum --- bitwarden_access_manager.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 1c34327..c2bca86 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -86,6 +86,9 @@ class MemberType(Enum): MANAGER = 3 CUSTOM = 4 +class GroupAccess(Enum): + READONLY = 0 + WRITE = 1 class Member(NamedTuple): id: str @@ -188,17 +191,17 @@ def format_toml(self) -> str: class GroupCollectionAccess(NamedTuple): name: str - access: str + access: GroupAccess @staticmethod def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: return GroupCollectionAccess( name=data["group_name"], - access=data["access"], + access=GroupAccess[data["access"].upper()], ) def format_toml(self) -> str: - return "{ group_name = " + self.name + '", access = "' + self.access + '" }' + return "{ group_name = " + self.name + '", access = "' + str(self.access).lower() + '" }' class Collection(NamedTuple): @@ -332,9 +335,9 @@ def get_collection_members( def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: for group in groups: if group["readOnly"] == True: - access = "readonly" + access = GroupAccess["READONLY"] else: - access = "write" + access = GroupAccess["WRITE"] group_id = group["id"] yield GroupCollectionAccess( From afdb1ca932a1425667311a77d5737c2a34da75c6 Mon Sep 17 00:00:00 2001 From: yannick Date: Fri, 17 Mar 2023 09:49:36 +0100 Subject: [PATCH 29/42] List direct member_access in collections --- bitwarden_access_manager.py | 100 ++++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 38 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index c2bca86..b65cd5e 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -86,10 +86,12 @@ class MemberType(Enum): MANAGER = 3 CUSTOM = 4 + class GroupAccess(Enum): READONLY = 0 WRITE = 1 + class Member(NamedTuple): id: str name: str @@ -178,15 +180,23 @@ def format_toml(self) -> str: class MemberCollectionAccess(NamedTuple): name: str + access: GroupAccess @staticmethod def from_toml_dict(data: Dict[str, Any]) -> MemberCollectionAccess: return MemberCollectionAccess( name=data["member_name"], + access=GroupAccess[data["access"].upper()], ) def format_toml(self) -> str: - return "{ member_name = " + self.name + '"}' + return ( + '{ member_name = "' + + self.name + + '", access = "' + + str(self.access.name).lower() + + '"}' + ) class GroupCollectionAccess(NamedTuple): @@ -201,7 +211,13 @@ def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: ) def format_toml(self) -> str: - return "{ group_name = " + self.name + '", access = "' + str(self.access).lower() + '" }' + return ( + '{ group_name = "' + + self.name + + '", access = "' + + str(self.access.name).lower() + + '" }' + ) class Collection(NamedTuple): @@ -317,27 +333,9 @@ def get_groups(self) -> Iterable[Group]: access_all=group["accessAll"], ) - def get_collection_members( - self, groups: List[Dict[str, Any]], org_members: Dict[str, Member] - ) -> Iterable[MemberCollectionAccess]: - for group in groups: - group_id = group["id"] - - member_ids = json.load( - self._http_get(f"/public/groups/{group_id}/member-ids") - ) - - for member_id in member_ids: - yield MemberCollectionAccess( - name=org_members[member_id].name, - ) - def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: for group in groups: - if group["readOnly"] == True: - access = GroupAccess["READONLY"] - else: - access = GroupAccess["WRITE"] + access = self.check_access(group["readOnly"]) group_id = group["id"] yield GroupCollectionAccess( @@ -345,7 +343,11 @@ def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: access=access, ) - def get_collections(self, org_members: Dict[str, Member]) -> Iterable[Collection]: + def get_collections( + self, + org_members: Dict[str, Member], + collections_members: Dict[str, List[MemberCollectionAccess]], + ) -> Iterable[Collection]: collections = json.load(self._http_get(f"/public/collections")) for collection in collections["data"]: @@ -363,16 +365,9 @@ def get_collections(self, org_members: Dict[str, Member]) -> Iterable[Collection if len(group_accesses_data) > 0: group_accesses = group_accesses_data - member_accesses_data = tuple( - sorted( - self.get_collection_members( - groups=collection_data["groups"], org_members=org_members - ) - ) - ) - if len(member_accesses_data) > 0: - member_accesses = member_accesses_data + if collection_id in collections_members: + member_accesses = tuple(sorted(collections_members[collection_id])) yield Collection( id=collection["id"], @@ -400,23 +395,51 @@ def set_member_type(self, type_id: int) -> MemberType: 3: MemberType.MANAGER, 4: MemberType.CUSTOM, } - return MemberType(int_to_member_type[type_id]) - def get_members(self) -> Iterable[Member]: + def get_members( + self, + ) -> tuple[List[Member], Dict[str, List[MemberCollectionAccess]]]: data = self._http_get(f"/public/members") members = json.load(data) + members_result: List[Member] = [] + collection_access: Dict[str, List[MemberCollectionAccess]] = {} + for member in members["data"]: type = self.set_member_type(member["type"]) - - yield Member( + m = Member( id=member["id"], name=member["name"], email=member["email"], type=type, access_all=member["accessAll"], ) + members_result.append(m) + + collections = json.load(self._http_get(f"/public/members/{member['id']}"))[ + "collections" + ] + if type != MemberType.OWNER and type != MemberType.ADMIN: + for collection in collections: + access = self.check_access(collection["readOnly"]) + + if collection["id"] not in collection_access: + collection_access[collection["id"]] = [ + MemberCollectionAccess(name=member["name"], access=access) + ] + else: + collection_access[collection["id"]].append( + MemberCollectionAccess(name=member["name"], access=access) + ) + + return members_result, collection_access + + def check_access(self, readonly: bool) -> GroupAccess: + if readonly == True: + return GroupAccess["READONLY"] + else: + return GroupAccess["WRITE"] class Configuration(NamedTuple): @@ -640,8 +663,9 @@ def main() -> None: target = Configuration.from_toml_file(target_fname) client = BitwardenClient.new(client_id, client_secret) - current_members = set(client.get_members()) - members_diff = Diff.new(target=target.member, actual=current_members) + current_members, members_access = client.get_members() + current_members_set = set(current_members) + members_diff = Diff.new(target=target.member, actual=current_members_set) members_diff.print_diff( f"The following members are specified in {target_fname} but not a member of the Bitwarden organization:", f"The following members are not specified in {target_fname} but are a member of the Bitwarden organization:", @@ -649,7 +673,7 @@ def main() -> None: ) org_members: Dict[str, Member] = {member.id: member for member in current_members} - current_collections = set(client.get_collections(org_members)) + current_collections = set(client.get_collections(org_members, members_access)) collections_diff = Diff.new(target=target.collection, actual=current_collections) collections_diff.print_diff( f"The following collections are specified in {target_fname} but not a member of the Bitwarden organization:", From bed3c8da513b65593f194f1c9a1be74da8efa634 Mon Sep 17 00:00:00 2001 From: yannick Date: Fri, 17 Mar 2023 10:58:11 +0100 Subject: [PATCH 30/42] Update docstring and add copyright --- bitwarden_access_manager.py | 49 ++++++++++++++++++++++++++++--------- main.py | 2 +- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index b65cd5e..dbe7977 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -1,54 +1,81 @@ #!/usr/bin/env python3 +# Copyright 2022 Chorus One + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# A copy of the License has been included in the root of the repository. + """ Bitwarden Access Manager +Compare the current state of a Bitwarden organization with a desired state +expressed in a TOML file. Currently this tool only points out the differences, +it does not automatically reconcile them for you. + +USAGE + + ./bitwarden_access_manager.py organization.toml + +ENVIRONMENT + +Requires BITWARDEN_CLIENT_ID and BITWARDEN_CLIENT_SECRET to be set in the environment. +Those must contain OAuth2 client credentials for the organization. Only Bitwarden +members of the organization with OWNER role have access to those credentials. + +You can view the credentials at https://vault.bitwarden.com/#/organizations//settings/account + CONFIGURATION +* The access_all key for members and groups is optional, default is false. +* The member_access key for a collection only list members with direct access +to collection. It omits direct access for members with the role +owners or admins because they have implicit access to all collections. + [[member]] member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4" member_name = "yan" email = "yan.68@hotmail.fr" type = "member" -groups = ["group1", "group2"] +groups = ["group1"] [[member]] member_id = "856cba2d-cae1-40e7-96cc-afaf00a8a4cb" member_name = "yunkel" email = "yunkel68@hotmail.fr" type = "owner" -access_all = true # access_all is optional, default is false -groups = ["group1"] +access_all = true +groups = ["group1", "group2"] [[group]] group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" group_name = "group1" -access_all = false +access_all = true [[group]] group_id = "39b48ab2-81fd-40eb-87e9-afb0000110f3" group_name = "group2" -access_all = false [[collection]] collection_id = "50351c20-55b4-4ee8-bbe0-afaf00a8f25d" external_id = "collection1" member_access = [ - { member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4", member_name = "yan", group = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" }, - ] + { member_name = "yan", access = "write"},, +] + group_access = [ - { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", read_only = true }, - { group_id = "39b48ab2-81fd-40eb-87e9-afb0000110f3", group_name = "group2", read_only = true }, + { group_name = "group1", access = "readonly"}, + { group_name = "group2", access = "write" }, ] [[collection]] collection_id = "8e69ce49-85ae-4e09-a52c-afaf00a90a3f" external_id = "" member_access = [ - { member_id = "2564c11f-fc1b-4ec7-aa0b-afaf00a9e4a4", member_name = "yan", group = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f" }, + { member_name = "yan", access = "write" }, ] group_access = [ - { group_id = "c6a13b93-edc1-4c3b-9fc5-afaf00a8d33f", group_name = "group1", read_only = false }, + { group_name = "group1", access = "readonly" }, ] """ diff --git a/main.py b/main.py index b086439..6f9f134 100755 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ """ Github Access Manager -Comare the current state of a GitHub organization against a declarative +Compare the current state of a GitHub organization against a declarative specification of the target state. Currently this tool only points out the differences, it does not automatically reconcile them for you. From f7c0ffba21d76ecf357e5e12c248b68591458a58 Mon Sep 17 00:00:00 2001 From: yannick Date: Fri, 17 Mar 2023 11:05:25 +0100 Subject: [PATCH 31/42] Remove not useful string conversion for access format_toml --- bitwarden_access_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index dbe7977..6650245 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -149,7 +149,7 @@ def format_toml(self) -> str: f"member_id = {self.id}", f"member_name = {self.name}", f"email = {self.email}", - f"type = {str(self.type)}", + f"type = {self.type.name.lower()}", f"access_all = {str(self.access_all).lower()}", ] return "\n".join(lines) @@ -221,7 +221,7 @@ def format_toml(self) -> str: '{ member_name = "' + self.name + '", access = "' - + str(self.access.name).lower() + + self.access.name.lower() + '"}' ) @@ -242,7 +242,7 @@ def format_toml(self) -> str: '{ group_name = "' + self.name + '", access = "' - + str(self.access.name).lower() + + self.access.name.lower() + '" }' ) From 96bf04aa035673fbf190bfb435aced5507cd3c10 Mon Sep 17 00:00:00 2001 From: yannick Date: Fri, 17 Mar 2023 11:16:27 +0100 Subject: [PATCH 32/42] Add quotes to format_toml outputs --- bitwarden_access_manager.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 6650245..3b5e475 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -146,10 +146,10 @@ def from_toml_dict(data: Dict[str, Any]) -> Member: def format_toml(self) -> str: lines = [ "[[member]]", - f"member_id = {self.id}", - f"member_name = {self.name}", - f"email = {self.email}", - f"type = {self.type.name.lower()}", + f'member_id = "{self.id}"', + f'member_name = "{self.name}"', + f'email = "{self.email}"', + f'type = "{self.type.name.lower()}"', f"access_all = {str(self.access_all).lower()}", ] return "\n".join(lines) @@ -198,9 +198,9 @@ def from_toml_dict(data: Dict[str, Any]) -> Group: def format_toml(self) -> str: lines = [ "[[group]]", - f"group_id = {self.id}", - f"group_name = {self.name}", - f"access_all = {str(self.access_all).lower()}", + f'group_id = "{self.id}"', + f'group_name = "{self.name}"', + f'access_all = "{str(self.access_all).lower()}"', ] return "\n".join(lines) @@ -286,8 +286,8 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: def format_toml(self) -> str: result = ( "[[collection]]\n" - f"collection_id = {self.id}\n" - f"external_id = {self.external_id}\n" + f'collection_id = "{self.id}"\n' + f'external_id = "{self.external_id}"\n' ) if self.member_access is not None: From b0e8a2f09be0079a4af38282b67d96d06b563b4c Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 10:43:28 +0200 Subject: [PATCH 33/42] Add groups to Member When a member is not part of a group, it will be reflected in Member diff. Therefore group diff is not necessary and will result in duplicated information. group_diff is removed. --- bitwarden_access_manager.py | 127 +++++++++++++++++------------------- 1 file changed, 59 insertions(+), 68 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 3b5e475..eca8f74 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -60,7 +60,7 @@ collection_id = "50351c20-55b4-4ee8-bbe0-afaf00a8f25d" external_id = "collection1" member_access = [ - { member_name = "yan", access = "write"},, + { member_name = "yan", access = "write"}, ] group_access = [ @@ -125,7 +125,7 @@ class Member(NamedTuple): email: str type: MemberType access_all: bool - # groups: Tuple[str, ...] + groups: Tuple[str, ...] def get_id(self) -> str: return self.id @@ -133,30 +133,44 @@ def get_id(self) -> str: @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Member: access_all: bool = False + groups: Tuple[str, ...] = tuple() + if "access_all" in data: access_all = data["access_all"] + if "groups" in data: + groups = data["groups"] + groups = tuple(sorted(data["groups"])) return Member( id=data["member_id"], name=data["member_name"], email=data["email"], type=MemberType[data["type"].upper()], access_all=access_all, + groups=groups, ) def format_toml(self) -> str: - lines = [ - "[[member]]", - f'member_id = "{self.id}"', - f'member_name = "{self.name}"', - f'email = "{self.email}"', - f'type = "{self.type.name.lower()}"', - f"access_all = {str(self.access_all).lower()}", - ] - return "\n".join(lines) + result = ( + "[[member]]\n" + f'member_id = "{self.id}"\n' + f'member_name = "{self.name}"\n' + f'email = "{self.email}"\n' + f'type = "{self.type.name.lower()}\n"' + f"access_all = {str(self.access_all).lower()}\n" + ) + + groups = self.groups or () + if len(groups) > 0: + groups_str = ", ".join(f'"{g}"' for g in sorted(groups)) + result = result + "groups = [ " + groups_str + " ]" + else: + result = result + "groups = []" + + return result class GroupMember(NamedTuple): - member_id: int + member_id: str member_name: str group_name: str @@ -425,22 +439,26 @@ def set_member_type(self, type_id: int) -> MemberType: return MemberType(int_to_member_type[type_id]) def get_members( - self, + self, member_groups: Dict[str, List[str]] ) -> tuple[List[Member], Dict[str, List[MemberCollectionAccess]]]: data = self._http_get(f"/public/members") members = json.load(data) members_result: List[Member] = [] collection_access: Dict[str, List[MemberCollectionAccess]] = {} + groups: Tuple[str, ...] = tuple() for member in members["data"]: type = self.set_member_type(member["type"]) + if member["id"] in member_groups: + groups = tuple(sorted(member_groups[member["id"]])) m = Member( id=member["id"], name=member["name"], email=member["email"], type=type, access_all=member["accessAll"], + groups=groups, ) members_result.append(m) @@ -634,36 +652,6 @@ def print_diff( print() -def print_group_members_diff( - *, - group_name: str, - target_fname: str, - target_members: Set[GroupMember], - actual_members: Set[GroupMember], -) -> None: - members_diff = Diff.new( - target=target_members, - actual=actual_members, - ) - if len(members_diff.to_remove) > 0: - print( - f"The following members of group '{group_name}' are not specified " - f"in {target_fname}, but are present on Bitwarden:\n" - ) - for member in sorted(members_diff.to_remove): - print(f" {member.member_name}") - print() - - if len(members_diff.to_add) > 0: - print( - f"The following members of group '{group_name}' are specified " - f"in {target_fname}, but are not present on Bitwarden:\n" - ) - for member in sorted(members_diff.to_add): - print(f" {member.member_name}") - print() - - def main() -> None: if "--help" in sys.argv: print(__doc__) @@ -690,7 +678,34 @@ def main() -> None: target = Configuration.from_toml_file(target_fname) client = BitwardenClient.new(client_id, client_secret) - current_members, members_access = client.get_members() + current_groups = set(client.get_groups()) + groups_diff = Diff.new(target=target.group, actual=current_groups) + groups_diff.print_diff( + f"The following groups specified in {target_fname} are not present on Bitwarden:", + f"The following groups are not specified in {target_fname} but are present on Bitwarden:", + f"The following groups on Bitwarden need to be changed to match {target_fname}:", + ) + + # For all the groups which we want to exist, and which do actually exist, + # compare their members. + target_groups_names = {group.name for group in target.group} + existing_desired_groups = [ + group for group in current_groups if group.name in target_groups_names + ] + + member_groups: Dict[str, List[str]] = {} + + for group in existing_desired_groups: + group_members = set(client.get_group_members(group.id, group.name)) + + # Create a Dict mapping member ids to the groups they are a member of. + for group_member in group_members: + if group_member.member_id not in member_groups: + member_groups[group_member.member_id] = [group.name] + else: + member_groups[group_member.member_id].append(group.name) + + current_members, members_access = client.get_members(member_groups) current_members_set = set(current_members) members_diff = Diff.new(target=target.member, actual=current_members_set) members_diff.print_diff( @@ -708,30 +723,6 @@ def main() -> None: f"The following collections on Bitwarden need to be changed to match {target_fname}:", ) - current_groups = set(client.get_groups()) - groups_diff = Diff.new(target=target.group, actual=current_groups) - groups_diff.print_diff( - f"The following groups specified in {target_fname} are not present on Bitwarden:", - f"The following groups are not specified in {target_fname} but are present on Bitwarden:", - f"The following groups on Bitwarden need to be changed to match {target_fname}:", - ) - - # For all the groups which we want to exist, and which do actually exist, - # compare their members. - target_groups_names = {group.name for group in target.group} - existing_desired_groups = [ - group for group in current_groups if group.name in target_groups_names - ] - for group in existing_desired_groups: - print_group_members_diff( - group_name=group.name, - target_fname=target_fname, - target_members={ - m for m in target.group_memberships if m.group_name == group.name - }, - actual_members=set(client.get_group_members(group.id, group.name)), - ) - if __name__ == "__main__": main() From cda04f02c7fcdfb6a3af943476a052c413fb6493 Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 10:53:09 +0200 Subject: [PATCH 34/42] Change group/member_access type for Collection --- bitwarden_access_manager.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index eca8f74..4ad2e97 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -264,15 +264,15 @@ def format_toml(self) -> str: class Collection(NamedTuple): id: str external_id: str - group_access: Optional[Tuple[GroupCollectionAccess, ...]] - member_access: Optional[Tuple[MemberCollectionAccess, ...]] + group_access: Tuple[GroupCollectionAccess, ...] + member_access: Tuple[MemberCollectionAccess, ...] def get_id(self) -> str: return self.id @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Collection: - group_access: Optional[Tuple[GroupCollectionAccess, ...]] = None + group_access: Tuple[GroupCollectionAccess, ...] = tuple() if "group_access" in data: group_access = tuple( sorted( @@ -281,7 +281,7 @@ def from_toml_dict(data: Dict[str, Any]) -> Collection: ) ) - member_access: Optional[Tuple[MemberCollectionAccess, ...]] = None + member_access: Tuple[MemberCollectionAccess, ...] = tuple() if "member_access" in data: member_access = tuple( sorted( @@ -392,8 +392,8 @@ def get_collections( collections = json.load(self._http_get(f"/public/collections")) for collection in collections["data"]: - group_accesses: Optional[Tuple[GroupCollectionAccess, ...]] = None - member_accesses: Optional[Tuple[MemberCollectionAccess, ...]] = None + group_accesses: Tuple[GroupCollectionAccess, ...] = tuple() + member_accesses: Tuple[MemberCollectionAccess, ...] = tuple() collection_id = collection["id"] collection_data = json.load( From 22d7d4486ea2baafbd7a820b73e036b8351c2c8f Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 11:16:35 +0200 Subject: [PATCH 35/42] Rename get_access to map_access --- bitwarden_access_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 4ad2e97..a2d2179 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -376,7 +376,7 @@ def get_groups(self) -> Iterable[Group]: def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: for group in groups: - access = self.check_access(group["readOnly"]) + access = self.map_access(readonly=group["readOnly"]) group_id = group["id"] yield GroupCollectionAccess( @@ -467,7 +467,7 @@ def get_members( ] if type != MemberType.OWNER and type != MemberType.ADMIN: for collection in collections: - access = self.check_access(collection["readOnly"]) + access = self.map_access(readonly=collection["readOnly"]) if collection["id"] not in collection_access: collection_access[collection["id"]] = [ @@ -480,7 +480,7 @@ def get_members( return members_result, collection_access - def check_access(self, readonly: bool) -> GroupAccess: + def map_access(self, *, readonly: bool) -> GroupAccess: if readonly == True: return GroupAccess["READONLY"] else: From 00bd82047496cc7ad62fd3f70e6ff51a1a21c437 Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 11:23:20 +0200 Subject: [PATCH 36/42] Rename MemberCollectionAccess and GroupCollectionAccess attributes --- bitwarden_access_manager.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index a2d2179..9cc74b0 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -220,20 +220,20 @@ def format_toml(self) -> str: class MemberCollectionAccess(NamedTuple): - name: str + member_name: str access: GroupAccess @staticmethod def from_toml_dict(data: Dict[str, Any]) -> MemberCollectionAccess: return MemberCollectionAccess( - name=data["member_name"], + member_name=data["member_name"], access=GroupAccess[data["access"].upper()], ) def format_toml(self) -> str: return ( '{ member_name = "' - + self.name + + self.member_name + '", access = "' + self.access.name.lower() + '"}' @@ -241,20 +241,20 @@ def format_toml(self) -> str: class GroupCollectionAccess(NamedTuple): - name: str + group_name: str access: GroupAccess @staticmethod def from_toml_dict(data: Dict[str, Any]) -> GroupCollectionAccess: return GroupCollectionAccess( - name=data["group_name"], + group_name=data["group_name"], access=GroupAccess[data["access"].upper()], ) def format_toml(self) -> str: return ( '{ group_name = "' - + self.name + + self.group_name + '", access = "' + self.access.name.lower() + '" }' @@ -380,7 +380,7 @@ def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: group_id = group["id"] yield GroupCollectionAccess( - name=json.load(self._http_get(f"/public/groups/{group_id}"))["name"], + group_name=json.load(self._http_get(f"/public/groups/{group_id}"))["name"], access=access, ) @@ -417,15 +417,15 @@ def get_collections( group_access=group_accesses, ) - def get_group_members(self, id: str, name: str) -> Iterable[GroupMember]: - members = json.load(self._http_get(f"/public/groups/{id}/member-ids")) + def get_group_members(self, group_id: str, group_name: str) -> Iterable[GroupMember]: + members = json.load(self._http_get(f"/public/groups/{group_id}/member-ids")) for member in members: member = json.load(self._http_get(f"/public/members/{member}")) yield GroupMember( member_id=member["id"], member_name=member["name"], - group_name=name, + group_name=group_name, ) def set_member_type(self, type_id: int) -> MemberType: @@ -471,11 +471,11 @@ def get_members( if collection["id"] not in collection_access: collection_access[collection["id"]] = [ - MemberCollectionAccess(name=member["name"], access=access) + MemberCollectionAccess(member_name=member["name"], access=access) ] else: collection_access[collection["id"]].append( - MemberCollectionAccess(name=member["name"], access=access) + MemberCollectionAccess(member_name=member["name"], access=access) ) return members_result, collection_access From 5b326d54ecb2f01a61ae8d08726ffd473e3f36d5 Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 11:29:33 +0200 Subject: [PATCH 37/42] Automaticaly build int_to_member_type dict --- bitwarden_access_manager.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 9cc74b0..e3b40b3 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -429,13 +429,8 @@ def get_group_members(self, group_id: str, group_name: str) -> Iterable[GroupMem ) def set_member_type(self, type_id: int) -> MemberType: - int_to_member_type: Dict[int, MemberType] = { - 0: MemberType.OWNER, - 1: MemberType.ADMIN, - 2: MemberType.USER, - 3: MemberType.MANAGER, - 4: MemberType.CUSTOM, - } + int_to_member_type: Dict[int, MemberType] = {variant.value: variant for variant in MemberType} + return MemberType(int_to_member_type[type_id]) def get_members( From cc2c08119cffefd8a6315fcd0fef9dcd40e5dcd0 Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 11:40:51 +0200 Subject: [PATCH 38/42] Use default_dict --- bitwarden_access_manager.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index e3b40b3..eb012bb 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -80,6 +80,7 @@ """ from __future__ import annotations +from collections import defaultdict from dataclasses import dataclass from difflib import SequenceMatcher from enum import Enum @@ -96,7 +97,6 @@ Dict, List, Generic, - Optional, Tuple, NamedTuple, Set, @@ -380,7 +380,9 @@ def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: group_id = group["id"] yield GroupCollectionAccess( - group_name=json.load(self._http_get(f"/public/groups/{group_id}"))["name"], + group_name=json.load(self._http_get(f"/public/groups/{group_id}"))[ + "name" + ], access=access, ) @@ -417,7 +419,9 @@ def get_collections( group_access=group_accesses, ) - def get_group_members(self, group_id: str, group_name: str) -> Iterable[GroupMember]: + def get_group_members( + self, group_id: str, group_name: str + ) -> Iterable[GroupMember]: members = json.load(self._http_get(f"/public/groups/{group_id}/member-ids")) for member in members: @@ -429,7 +433,9 @@ def get_group_members(self, group_id: str, group_name: str) -> Iterable[GroupMem ) def set_member_type(self, type_id: int) -> MemberType: - int_to_member_type: Dict[int, MemberType] = {variant.value: variant for variant in MemberType} + int_to_member_type: Dict[int, MemberType] = { + variant.value: variant for variant in MemberType + } return MemberType(int_to_member_type[type_id]) @@ -440,7 +446,7 @@ def get_members( members = json.load(data) members_result: List[Member] = [] - collection_access: Dict[str, List[MemberCollectionAccess]] = {} + collection_access: Dict[str, List[MemberCollectionAccess]] = defaultdict(lambda: []) groups: Tuple[str, ...] = tuple() for member in members["data"]: @@ -464,14 +470,9 @@ def get_members( for collection in collections: access = self.map_access(readonly=collection["readOnly"]) - if collection["id"] not in collection_access: - collection_access[collection["id"]] = [ - MemberCollectionAccess(member_name=member["name"], access=access) - ] - else: - collection_access[collection["id"]].append( - MemberCollectionAccess(member_name=member["name"], access=access) - ) + collection_access[collection["id"]].append( + MemberCollectionAccess(member_name=member["name"], access=access) + ) return members_result, collection_access From d097be834a50475e52a4b02e7894bbedde97aa5d Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 12:04:26 +0200 Subject: [PATCH 39/42] inline get_collection_groups in get_collections --- bitwarden_access_manager.py | 38 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index eb012bb..aac5e0c 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -374,18 +374,6 @@ def get_groups(self) -> Iterable[Group]: access_all=group["accessAll"], ) - def get_collection_groups(self, groups: Any) -> Iterable[GroupCollectionAccess]: - for group in groups: - access = self.map_access(readonly=group["readOnly"]) - - group_id = group["id"] - yield GroupCollectionAccess( - group_name=json.load(self._http_get(f"/public/groups/{group_id}"))[ - "name" - ], - access=access, - ) - def get_collections( self, org_members: Dict[str, Member], @@ -402,9 +390,21 @@ def get_collections( self._http_get(f"/public/collections/{collection_id}") ) - group_accesses_data = tuple( - sorted(self.get_collection_groups(collection_data["groups"])) - ) + group_collection_accesses: List[GroupCollectionAccess] = [] + + for group in collection_data["groups"]: + access = self.map_access(readonly=group["readOnly"]) + group_id = group["id"] + group_collection_accesses.append( + GroupCollectionAccess( + group_name=json.load( + self._http_get(f"/public/groups/{group_id}") + )["name"], + access=access, + ) + ) + + group_accesses_data = tuple(sorted(group_collection_accesses)) if len(group_accesses_data) > 0: group_accesses = group_accesses_data @@ -446,7 +446,9 @@ def get_members( members = json.load(data) members_result: List[Member] = [] - collection_access: Dict[str, List[MemberCollectionAccess]] = defaultdict(lambda: []) + collection_access: Dict[str, List[MemberCollectionAccess]] = defaultdict( + lambda: [] + ) groups: Tuple[str, ...] = tuple() for member in members["data"]: @@ -471,7 +473,9 @@ def get_members( access = self.map_access(readonly=collection["readOnly"]) collection_access[collection["id"]].append( - MemberCollectionAccess(member_name=member["name"], access=access) + MemberCollectionAccess( + member_name=member["name"], access=access + ) ) return members_result, collection_access From 1c9f2c7c7775cec1968f52f078c638ebd8fd689a Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 28 Mar 2023 14:17:22 +0200 Subject: [PATCH 40/42] Automaticaly build member_groups dict --- bitwarden_access_manager.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index aac5e0c..19c914e 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -693,17 +693,14 @@ def main() -> None: group for group in current_groups if group.name in target_groups_names ] - member_groups: Dict[str, List[str]] = {} + member_groups: Dict[str, List[str]] = defaultdict(lambda: []) for group in existing_desired_groups: group_members = set(client.get_group_members(group.id, group.name)) # Create a Dict mapping member ids to the groups they are a member of. for group_member in group_members: - if group_member.member_id not in member_groups: - member_groups[group_member.member_id] = [group.name] - else: - member_groups[group_member.member_id].append(group.name) + member_groups[group_member.member_id].append(group.name) current_members, members_access = client.get_members(member_groups) current_members_set = set(current_members) From f858a7395a171838cea25d7f0f06fa7bbc34c056 Mon Sep 17 00:00:00 2001 From: yannick Date: Thu, 30 Mar 2023 14:35:46 +0200 Subject: [PATCH 41/42] Script cleanup --- bitwarden_access_manager.py | 88 ++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py index 19c914e..94a91c1 100644 --- a/bitwarden_access_manager.py +++ b/bitwarden_access_manager.py @@ -23,7 +23,7 @@ Those must contain OAuth2 client credentials for the organization. Only Bitwarden members of the organization with OWNER role have access to those credentials. -You can view the credentials at https://vault.bitwarden.com/#/organizations//settings/account +You can view the credentials at https://vault.bitwarden.com/#/organizations//settings/account CONFIGURATION @@ -159,12 +159,7 @@ def format_toml(self) -> str: f"access_all = {str(self.access_all).lower()}\n" ) - groups = self.groups or () - if len(groups) > 0: - groups_str = ", ".join(f'"{g}"' for g in sorted(groups)) - result = result + "groups = [ " + groups_str + " ]" - else: - result = result + "groups = []" + result = result + "groups = [" + ", ".join(json.dumps(g) for g in sorted(self.groups)) + "]" return result @@ -304,33 +299,32 @@ def format_toml(self) -> str: f'external_id = "{self.external_id}"\n' ) - if self.member_access is not None: - member_access_lines = [ - " " + a.format_toml() for a in sorted(self.member_access) - ] - if len(member_access_lines) > 0: - result = ( - result - + "member_access = [\n" - + ",\n".join(member_access_lines) - + ",\n]\n" - ) - else: - result = result + "member_access = []\n" + member_access_lines = [ + " " + a.format_toml() for a in sorted(self.member_access) + ] + if len(member_access_lines) > 0: + result = ( + result + + "member_access = [\n" + + ",\n".join(member_access_lines) + + ",\n]\n" + ) + else: + result = result + "member_access = []\n" + + group_access_lines = [ + " " + a.format_toml() for a in sorted(self.group_access) + ] + if len(group_access_lines) > 0: + result = ( + result + + "group_access = [\n" + + ",\n".join(group_access_lines) + + ",\n]" + ) + else: + result = result + "group_access = []" - if self.group_access is not None: - group_access_lines = [ - " " + a.format_toml() for a in sorted(self.group_access) - ] - if len(group_access_lines) > 0: - result = ( - result - + "group_access = [\n" - + ",\n".join(group_access_lines) - + ",\n]" - ) - else: - result = result + "group_access = []" return result @@ -404,10 +398,7 @@ def get_collections( ) ) - group_accesses_data = tuple(sorted(group_collection_accesses)) - - if len(group_accesses_data) > 0: - group_accesses = group_accesses_data + group_accesses = tuple(sorted(group_collection_accesses)) if collection_id in collections_members: member_accesses = tuple(sorted(collections_members[collection_id])) @@ -441,7 +432,7 @@ def set_member_type(self, type_id: int) -> MemberType: def get_members( self, member_groups: Dict[str, List[str]] - ) -> tuple[List[Member], Dict[str, List[MemberCollectionAccess]]]: + ) -> Tuple[List[Member], Dict[str, List[MemberCollectionAccess]]]: data = self._http_get(f"/public/members") members = json.load(data) @@ -453,8 +444,7 @@ def get_members( for member in members["data"]: type = self.set_member_type(member["type"]) - if member["id"] in member_groups: - groups = tuple(sorted(member_groups[member["id"]])) + groups = tuple(sorted(member_groups[member["id"]])) m = Member( id=member["id"], name=member["name"], @@ -489,15 +479,15 @@ def map_access(self, *, readonly: bool) -> GroupAccess: class Configuration(NamedTuple): collection: Set[Collection] - member: Set[Member] - group: Set[Group] + members: Set[Member] + groups: Set[Group] group_memberships: Set[GroupMember] @staticmethod def from_toml_dict(data: Dict[str, Any]) -> Configuration: collection = {Collection.from_toml_dict(c) for c in data["collection"]} - member = {Member.from_toml_dict(m) for m in data["member"]} - group = {Group.from_toml_dict(m) for m in data["group"]} + members = {Member.from_toml_dict(m) for m in data["member"]} + groups = {Group.from_toml_dict(m) for m in data["group"]} group_memberships = { GroupMember( member_id=member["member_id"], @@ -509,8 +499,8 @@ def from_toml_dict(data: Dict[str, Any]) -> Configuration: } return Configuration( collection=collection, - member=member, - group=group, + members=members, + groups=groups, group_memberships=group_memberships, ) @@ -679,7 +669,7 @@ def main() -> None: client = BitwardenClient.new(client_id, client_secret) current_groups = set(client.get_groups()) - groups_diff = Diff.new(target=target.group, actual=current_groups) + groups_diff = Diff.new(target=target.groups, actual=current_groups) groups_diff.print_diff( f"The following groups specified in {target_fname} are not present on Bitwarden:", f"The following groups are not specified in {target_fname} but are present on Bitwarden:", @@ -688,7 +678,7 @@ def main() -> None: # For all the groups which we want to exist, and which do actually exist, # compare their members. - target_groups_names = {group.name for group in target.group} + target_groups_names = {group.name for group in target.groups} existing_desired_groups = [ group for group in current_groups if group.name in target_groups_names ] @@ -704,7 +694,7 @@ def main() -> None: current_members, members_access = client.get_members(member_groups) current_members_set = set(current_members) - members_diff = Diff.new(target=target.member, actual=current_members_set) + members_diff = Diff.new(target=target.members, actual=current_members_set) members_diff.print_diff( f"The following members are specified in {target_fname} but not a member of the Bitwarden organization:", f"The following members are not specified in {target_fname} but are a member of the Bitwarden organization:", From 150d17902189c805998f7ddaf09550cab74a91be Mon Sep 17 00:00:00 2001 From: yannick Date: Tue, 4 Apr 2023 17:24:51 +0200 Subject: [PATCH 42/42] Add execute flag --- bitwarden_access_manager.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 bitwarden_access_manager.py diff --git a/bitwarden_access_manager.py b/bitwarden_access_manager.py old mode 100644 new mode 100755