diff --git a/backend/infrahub/core/protocols.py b/backend/infrahub/core/protocols.py index fa227bd041..afb936d294 100644 --- a/backend/infrahub/core/protocols.py +++ b/backend/infrahub/core/protocols.py @@ -216,7 +216,7 @@ class CoreAccount(LineageOwner, LineageSource, CoreGenericAccount): pass -class CoreAccountGroup(CoreGroup): +class CoreAccountGroup(LineageOwner, LineageSource, CoreGroup): roles: RelationshipManager diff --git a/backend/infrahub/core/schema/definitions/core.py b/backend/infrahub/core/schema/definitions/core.py index ee1a95c269..8db9acb0fb 100644 --- a/backend/infrahub/core/schema/definitions/core.py +++ b/backend/infrahub/core/schema/definitions/core.py @@ -2264,7 +2264,7 @@ "display_labels": ["name__value"], "human_friendly_id": ["name__value"], "generate_profile": False, - "inherit_from": [InfrahubKind.GENERICGROUP], + "inherit_from": [InfrahubKind.LINEAGEOWNER, InfrahubKind.LINEAGESOURCE, InfrahubKind.GENERICGROUP], "branch": BranchSupportType.AGNOSTIC.value, "relationships": [ { diff --git a/docs/docs/tutorials/getting-started/readme.mdx b/docs/docs/tutorials/getting-started/readme.mdx index 93438bbee5..15b5e61924 100644 --- a/docs/docs/tutorials/getting-started/readme.mdx +++ b/docs/docs/tutorials/getting-started/readme.mdx @@ -79,12 +79,18 @@ Refer to [User management](/topics/auth/) page for more information regarding th To follow the tutorial you should use the `admin` account but you can try the other accounts too to see how the interface behaves with different permission levels. -| name | username | password | role | -| ------------- | --------------- | ------------- | ---------- | -| Administrator | `admin` | `infrahub` | admin | -| Chloe O'Brian | `Chloe O'Brian` | `Password123` | read-write | -| David Palmer | `David Palmer` | `Password123` | read-write | -| Jack Bauer | `Jack Bauer` | `Password123` | read-only | +| name | username | password | group | +| --------------- | --------------- | ------------- | -------------------- | +| Administrator | `admin` | `infrahub` | Super Administrators | +| Sue Dough | `sudo` | `Password123` | Administrators | +| Chloe O'Brian | `cobrian` | `Password123` | Engineering Team | +| Sofia Hernandez | `shernandez` | `Password123` | Engineering Team | +| Ryan Patel | `rpatel` | `Password123` | Engineering Team | +| Jack Bauer | `jbauer` | `Password123` | Operations Team | +| Emily Lawson | `elawson` | `Password123` | Operations Team | +| Jacob Thompson | `jthompson` | `Password123` | Operations Team | +| David Palmer | `dpalmer` | `Password123` | Architecture Team | +| Olivia Carter | `ocarter` | `Password123` | Architecture Team | ## Access the Infrahub interfaces diff --git a/frontend/app/tests/constants.ts b/frontend/app/tests/constants.ts index 4c702085eb..8b80fa29d8 100644 --- a/frontend/app/tests/constants.ts +++ b/frontend/app/tests/constants.ts @@ -4,12 +4,17 @@ export const ADMIN_CREDENTIALS = { }; export const READ_WRITE_CREDENTIALS = { - username: "Chloe O'Brian", + username: "cobrian", password: "Password123", }; export const READ_ONLY_CREDENTIALS = { - username: "Jack Bauer", + username: "jbauer", + password: "Password123", +}; + +export const ENG_TEAM_ONLY_CREDENTIALS = { + username: "shernandez", password: "Password123", }; diff --git a/frontend/app/tests/e2e/objects/object-metadata.spec.ts b/frontend/app/tests/e2e/objects/object-metadata.spec.ts index fb30acb35d..3c8385bdcf 100644 --- a/frontend/app/tests/e2e/objects/object-metadata.spec.ts +++ b/frontend/app/tests/e2e/objects/object-metadata.spec.ts @@ -38,8 +38,8 @@ test.describe("Object metadata", () => { // Select Architecture team await page.getByText("Owner Kind ?").getByLabel("Kind").first().click(); - await page.getByRole("option", { name: "Account" }).click(); - await page.getByText("Owner Kind ?").getByLabel("Account").click(); + await page.getByRole("option", { name: "Account group" }).click(); + await page.getByText("Owner Kind ?").getByLabel("Account group").click(); await page.getByRole("option", { name: "Architecture Team" }).click(); // Save @@ -61,7 +61,7 @@ test.describe("Object metadata", () => { await metadataTooltipUpdated.getByTestId("edit-metadata-button").click(); // Source should be Account + Pop-Builder - await expect(page.getByTestId("select-input").nth(0)).toHaveValue("Account"); + await expect(page.getByTestId("select-input").nth(0)).toHaveValue("Account group"); await expect(page.getByTestId("select-input").nth(1)).toHaveValue("Architecture Team"); // Is protected should be checked diff --git a/frontend/app/tests/e2e/profile/profile.spec.ts b/frontend/app/tests/e2e/profile/profile.spec.ts index 65756b541e..26be89955a 100644 --- a/frontend/app/tests/e2e/profile/profile.spec.ts +++ b/frontend/app/tests/e2e/profile/profile.spec.ts @@ -52,7 +52,7 @@ test.describe("/profile", () => { await expect( page.getByRole("heading", { name: "Chloe O'Brian", exact: true }) ).toBeVisible(); - await expect(page.getByText("NameChloe O'Brian")).toBeVisible(); + await expect(page.getByText("LabelChloe O'Brian")).toBeVisible(); await expect(page.getByText("Roleread-write")).toBeVisible(); }); }); @@ -70,7 +70,7 @@ test.describe("/profile", () => { await test.step("display account details", async () => { await expect(page.getByRole("heading", { name: "Jack Bauer", exact: true })).toBeVisible(); - await expect(page.getByText("NameJack Bauer")).toBeVisible(); + await expect(page.getByText("LabelJack Bauer")).toBeVisible(); await expect(page.getByText("Roleread-only")).toBeVisible(); }); }); diff --git a/frontend/app/tests/e2e/proposed-changes/proposed-changes.spec.ts b/frontend/app/tests/e2e/proposed-changes/proposed-changes.spec.ts index 00d3377d8d..d9c02e4bc3 100644 --- a/frontend/app/tests/e2e/proposed-changes/proposed-changes.spec.ts +++ b/frontend/app/tests/e2e/proposed-changes/proposed-changes.spec.ts @@ -71,8 +71,8 @@ test.describe("/proposed-changes", () => { await page.getByLabel("Name *").fill(pcName); await page.getByTestId("codemirror-editor").getByRole("textbox").fill("My description"); await page.getByTestId("select-open-option-button").click(); - await page.getByRole("option", { name: "Architecture Team" }).click(); - await page.getByRole("option", { name: "Crm Synchronization" }).click(); + await page.getByRole("option", { name: "Olivia Carter" }).click(); + await page.getByRole("option", { name: "CRM Synchronization" }).click(); await page.getByTestId("select-open-option-button").click(); await page.getByRole("button", { name: "Create proposed change" }).click(); @@ -103,7 +103,7 @@ test.describe("/proposed-changes", () => { await expect(page.getByRole("heading", { name: pcNameEdit, exact: true })).toBeVisible(); await expect(page.getByTestId("pc-description")).toContainText("My description edit"); - await expect(page.getByText("ReviewersAT")).toBeVisible(); + await expect(page.getByText("ReviewersOC")).toBeVisible(); }); }); diff --git a/frontend/app/tests/e2e/role-management/read.spec.ts b/frontend/app/tests/e2e/role-management/read.spec.ts index 4ef517bfd5..03e5743394 100644 --- a/frontend/app/tests/e2e/role-management/read.spec.ts +++ b/frontend/app/tests/e2e/role-management/read.spec.ts @@ -10,11 +10,11 @@ test.describe("Role management - READ", () => { }); await test.step("check counts", async () => { - await expect(page.getByRole("link", { name: "Accounts 9" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Groups 2" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Roles 3" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Accounts 12" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Groups 6" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Roles 7" })).toBeVisible(); await expect(page.getByRole("link", { name: "Global Permissions 8" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Object Permissions 3" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Object Permissions 4" })).toBeVisible(); }); await test.step("check accounts view", async () => { @@ -23,22 +23,24 @@ test.describe("Role management - READ", () => { }); await test.step("check groups view", async () => { - await page.getByRole("link", { name: "Groups 2" }).click(); - await expect(page.getByRole("cell", { name: "Super Administrators" }).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "+4" })).toBeVisible(); + await page.getByRole("link", { name: "Groups 6" }).click(); + await expect(page.getByRole("cell", { name: "Operations Team" })).toBeVisible(); + // Need to create more user to trigger this + // await expect(page.getByRole("cell", { name: "+ 4" })).toBeVisible(); }); await test.step("check roles view", async () => { - await page.getByRole("link", { name: "Roles 3" }).click(); + await page.getByRole("link", { name: "Roles 7" }).click(); await expect(page.getByText("General Access")).toBeVisible(); await expect(page.getByText("Infrahub Users")).toBeVisible(); - await expect(page.getByText("global:manage_repositories:")).toBeVisible(); + await expect(page.getByText("global:edit_default_branch:")).toBeVisible(); + await expect(page.getByRole("cell", { name: "1" }).first()).toBeVisible(); }); await test.step("check global permissions view", async () => { await page.getByRole("link", { name: "Global Permissions" }).click(); await expect(page.getByRole("cell", { name: "super_admin", exact: true })).toBeVisible(); - await expect(page.getByText("global:super_admin:allow")).toBeVisible(); + await expect(page.getByText("global:super_admin:")).toBeVisible(); }); }); }); diff --git a/frontend/app/tests/e2e/tutorial/tutorial-2_data-lineage-and-metadata.spec.ts b/frontend/app/tests/e2e/tutorial/tutorial-2_data-lineage-and-metadata.spec.ts index 9a7ac813b7..25a9274248 100644 --- a/frontend/app/tests/e2e/tutorial/tutorial-2_data-lineage-and-metadata.spec.ts +++ b/frontend/app/tests/e2e/tutorial/tutorial-2_data-lineage-and-metadata.spec.ts @@ -28,7 +28,7 @@ test.describe("Getting started with Infrahub - Data lineage and metadata", () => await test.step("Update Description attribute to make it protected", async () => { await page.getByTestId("edit-metadata-button").click(); await page.getByLabel("Kind").first().click(); - await page.getByRole("option", { name: "Account" }).click(); + await page.getByRole("option", { name: "Account" }).first().click(); await page.getByLabel("Account").click(); await page.getByRole("option", { name: "Admin" }).click(); await page.getByLabel("is protected *").check(); diff --git a/models/infrastructure_edge.py b/models/infrastructure_edge.py index d6949d8709..66a397e3c3 100644 --- a/models/infrastructure_edge.py +++ b/models/infrastructure_edge.py @@ -9,11 +9,15 @@ from infrahub_sdk import InfrahubClient from infrahub_sdk.batch import InfrahubBatch +from infrahub_sdk.exceptions import NodeNotFoundError from infrahub_sdk.protocols import ( CoreAccount, CoreAccountGroup, + CoreAccountRole, + CoreGlobalPermission, CoreIPAddressPool, CoreIPPrefixPool, + CoreObjectPermission, CoreStandardGroup, IpamNamespace, ) @@ -136,13 +140,38 @@ def translate_str_to_bool(key: str, value: str) -> bool: # pylint: skip-file +class AccountRole(BaseModel): + name: str + global_permissions: list[str] | str | None = None + object_permissions: list[str] | str | None = None + + +class AccountGroup(BaseModel): + name: str + roles: list[str] = Field(default_factory=list) + members: list[str] = Field(default_factory=list) + + class Account(BaseModel): name: str + label: str password: str account_type: str role: str +class GlobalPermission(BaseModel): + action: str + decision: int + + +class ObjectPermission(BaseModel): + namespace: str + name: str + action: str + decision: int + + class Asn(BaseModel): asn: int organization: str @@ -621,17 +650,64 @@ def site_generator(nbr_site: int = 2) -> list[Site]: INTERFACE_OBJS: dict[str, list[InfraInterfaceL3]] = defaultdict(list) +GLOBAL_PERMISSIONS = ( + GlobalPermission(action="edit_default_branch", decision=6), + GlobalPermission(action="merge_branch", decision=6), + GlobalPermission(action="merge_proposed_change", decision=6), + GlobalPermission(action="manage_schema", decision=6), + GlobalPermission(action="manage_accounts", decision=6), + GlobalPermission(action="manage_permissions", decision=6), + GlobalPermission(action="manage_repositories", decision=6), +) + +OBJECT_PERMISSIONS = { + "deny_any": ObjectPermission(namespace="*", name="*", action="any", decision=1), + "allow_any": ObjectPermission(namespace="*", name="*", action="any", decision=6), + "allow_branches": ObjectPermission(namespace="*", name="*", action="any", decision=4), + "view_any": ObjectPermission(namespace="*", name="*", action="view", decision=6), +} + +ACCOUNT_ROLES = ( + AccountRole(name="Administrator", global_permissions="__all__", object_permissions=["allow_any"]), + AccountRole(name="Global read-only", object_permissions=["deny_any", "view_any"]), + AccountRole( + name="Global read-write", + global_permissions=["edit_default_branch", "merge_branch", "merge_proposed_change"], + object_permissions=["allow_any"], + ), + AccountRole(name="Own branches read-write", object_permissions=["allow_branches"]), +) + ACCOUNTS = ( - Account(name="pop-builder", account_type="Script", password="Password123", role="read-write"), - Account(name="CRM Synchronization", account_type="Script", password="Password123", role="read-write"), - Account(name="Jack Bauer", account_type="User", password="Password123", role="read-only"), - Account(name="Chloe O'Brian", account_type="User", password="Password123", role="read-write"), - Account(name="David Palmer", account_type="User", password="Password123", role="read-write"), - Account(name="Operation Team", account_type="User", password="Password123", role="read-only"), - Account(name="Engineering Team", account_type="User", password="Password123", role="read-write"), - Account(name="Architecture Team", account_type="User", password="Password123", role="read-only"), + Account(name="pop-builder", label="pop-builder", account_type="Script", password="Password123", role="read-write"), + Account( + name="crm-sync", label="CRM Synchronization", account_type="Script", password="Password123", role="read-write" + ), + Account(name="jbauer", label="Jack Bauer", account_type="User", password="Password123", role="read-only"), + Account(name="cobrian", label="Chloe O'Brian", account_type="User", password="Password123", role="read-write"), + Account(name="dpalmer", label="David Palmer", account_type="User", password="Password123", role="read-write"), + Account(name="sudo", label="Sue Dough", password="Password123", role="admin", account_type="User"), + Account(name="elawson", label="Emily Lawson", password="Password123", role="read-write", account_type="User"), + Account(name="jthompson", label="Jacob Thompson", password="Password123", role="read-write", account_type="User"), + Account(name="shernandez", label="Sofia Hernandez", password="Password123", role="read-write", account_type="User"), + Account(name="rpatel", label="Ryan Patel", password="Password123", role="read-only", account_type="User"), + Account(name="ocarter", label="Olivia Carter", password="Password123", role="read-only", account_type="User"), ) +ACCOUNT_GROUPS = { + "administrators": AccountGroup( + name="Administrators", roles=["Administrator"], members=["sudo", "pop-builder", "crm-sync"] + ), + "ops-team": AccountGroup( + name="Operations Team", roles=["Global read-only"], members=["jbauer", "elawson", "jthompson"] + ), + "eng-team": AccountGroup( + name="Engineering Team", roles=["Global read-write"], members=["cobrian", "shernandez", "rpatel"] + ), + "arch-team": AccountGroup( + name="Architecture Team", roles=["Own branches read-write"], members=["dpalmer", "ocarter"] + ), +} GROUPS = ( Group(name="edge_router", label="Edge Router"), @@ -907,8 +983,8 @@ async def generate_site_vlans( client: InfrahubClient, log: logging.Logger, branch: str, site: Site, site_id: int ) -> None: account_pop = store.get("pop-builder", kind=CoreAccount, raise_when_missing=True) - group_eng = store.get("Engineering Team", kind=CoreAccount, raise_when_missing=True) - group_ops = store.get("Operation Team", kind=CoreAccount, raise_when_missing=True) + group_eng = store.get("eng-team", kind=CoreAccountGroup, raise_when_missing=True) + group_ops = store.get("ops-team", kind=CoreAccountGroup, raise_when_missing=True) for vlan in VLANS: vlan_name = f"{site.name}_{vlan.role}" @@ -986,10 +1062,10 @@ async def generate_site( external_pool: CoreNode, site_design: SiteDesign, ) -> str: - group_eng = store.get("Engineering Team", kind=CoreAccount) - group_ops = store.get("Operation Team", kind=CoreAccount) + group_eng = store.get("eng-team", kind=CoreAccountGroup) + group_ops = store.get("ops-team", kind=CoreAccountGroup) account_pop = store.get("pop-builder", kind=CoreAccount) - account_crm = store.get("CRM Synchronization", kind=CoreAccount) + account_crm = store.get("crm-sync", kind=CoreAccount) internal_as = store.get(kind=InfraAutonomousSystem, key="Duff") country = store.get(kind=LocationCountry, key=site.country) @@ -1757,22 +1833,122 @@ async def generate_continents_countries(client: InfrahubClient, log: logging.Log log.info("Created continents and countries") -async def prepare_accounts(client: InfrahubClient, log: logging.Logger, branch: str, batch: InfrahubBatch) -> None: - groups = await client.filters(branch=branch, kind=CoreAccountGroup, name__value="Super Administrators") - store.set(key=groups[0].name, node=groups[0]) +async def prepare_permissions(client: InfrahubClient, log: logging.Logger, branch: str, batch: InfrahubBatch) -> None: + for p in GLOBAL_PERMISSIONS: + obj = await client.get( + branch=branch, kind="CoreGlobalPermission", hfid=[p.action, str(p.decision)], raise_when_missing=True + ) + store.set(key=p.action, node=obj) + + for name, p in OBJECT_PERMISSIONS.items(): + try: + obj = await client.get( + branch=branch, kind="CoreObjectPermission", hfid=[p.namespace, p.name, p.action, str(p.decision)] + ) + except NodeNotFoundError: + obj = await client.create(branch=branch, kind="CoreObjectPermission", data=p.model_dump()) + batch.add(task=obj.save, node=obj) + store.set(key=name, node=obj) - for account in ACCOUNTS: - data = account.model_dump() - data["member_of_groups"] = groups - obj = await client.create(branch=branch, kind="CoreAccount", data=data) +async def prepare_account_roles(client: InfrahubClient, log: logging.Logger, branch: str, batch: InfrahubBatch) -> None: + for role in ACCOUNT_ROLES: + obj = await client.create( + branch=branch, + kind="CoreAccountRole", + data=role.model_dump(exclude={"global_permissions", "object_permissions"}), + ) + batch.add(task=obj.save, node=obj) + store.set(key=role.name, node=obj) + + +async def prepare_accounts(client: InfrahubClient, log: logging.Logger, branch: str, batch: InfrahubBatch) -> None: + for account in ACCOUNTS: + obj = await client.create(branch=branch, kind="CoreAccount", data=account.model_dump(exclude={"groups"})) batch.add(task=obj.save, node=obj) store.set(key=account.name, node=obj) + for name, group in ACCOUNT_GROUPS.items(): + obj = await client.create( + branch=branch, kind="CoreAccountGroup", data=group.model_dump(exclude={"roles", "members"}) + ) + batch.add(task=obj.save, node=obj) + store.set(key=name, node=obj) + + +async def map_permissions_to_roles( + client: InfrahubClient, log: logging.Logger, branch: str, batch: InfrahubBatch +) -> None: + for role in ACCOUNT_ROLES: + if not role.global_permissions and not role.object_permissions: + continue + + obj = store.get(role.name, kind=CoreAccountRole, raise_when_missing=True) + await obj.permissions.fetch() + + permissions: list[CoreGlobalPermission | CoreObjectPermission] = [] + if role.global_permissions: + if isinstance(role.global_permissions, str) and role.global_permissions == "__all__": + permissions.extend( + [ + store.get(p.action, kind=CoreGlobalPermission, raise_when_missing=True) + for p in GLOBAL_PERMISSIONS + ] + ) + else: + permissions.extend( + [ + store.get(p_name, kind=CoreGlobalPermission, raise_when_missing=True) + for p_name in role.global_permissions + ] + ) + if role.object_permissions: + if isinstance(role.object_permissions, str) and role.object_permissions == "__all__": + permissions.extend( + [ + store.get(p_name, kind=CoreObjectPermission, raise_when_missing=True) + for p_name in GLOBAL_PERMISSIONS + ] + ) + else: + permissions.extend( + [ + store.get(p_name, kind=CoreObjectPermission, raise_when_missing=True) + for p_name in role.object_permissions + ] + ) + + obj.permissions.extend(permissions) + batch.add(task=obj.save, node=obj) + + +async def map_user_and_roles_to_groups( + client: InfrahubClient, log: logging.Logger, branch: str, batch: InfrahubBatch +) -> None: + for group_name, group in ACCOUNT_GROUPS.items(): + updated = False + obj = store.get(group_name, kind=CoreAccountGroup, raise_when_missing=True) + + if group.roles: + await obj.roles.fetch() + obj.roles.extend( + data=[store.get(role, kind=CoreAccountRole, raise_when_missing=True) for role in group.roles] + ) + updated = True + if group.members: + await obj.members.fetch() + obj.members.extend( + data=[store.get(member, kind=CoreAccount, raise_when_missing=True) for member in group.members] + ) + updated = True + + if updated: + batch.add(task=obj.save, node=obj) + async def prepare_asns(client: InfrahubClient, log: logging.Logger, branch: str, batch: InfrahubBatch) -> None: - account_chloe = store.get("Chloe O'Brian", kind=CoreAccount, raise_when_missing=True) - account_crm = store.get("CRM Synchronization", kind=CoreAccount, raise_when_missing=True) + account_chloe = store.get("cobrian", kind=CoreAccount, raise_when_missing=True) + account_crm = store.get("crm-sync", kind=CoreAccount, raise_when_missing=True) organizations_dict = {org.name: org.type for org in ORGANIZATIONS} for asn in ASNS: organization_type = organizations_dict.get(asn.organization, None) @@ -1946,10 +2122,32 @@ async def run( # ------------------------------------------ # Create User Accounts, Groups, Organizations & Platforms # ------------------------------------------ - log.info("Creating User Accounts, Groups & Organizations & Platforms") + log.info("Creating User Accounts, Groups, Roles, Permissions & Organizations & Platforms") + + batch = await client.create_batch() + await prepare_permissions(client=client, log=log, branch=branch, batch=batch) + await prepare_account_roles(client=client, log=log, branch=branch, batch=batch) + async for node, _ in batch.execute(): + if hasattr(node, "name"): + log.info(f"- Created {node._schema.kind} - {node.name.value}") + else: + log.info(f"- Created {node._schema.kind} - {node}") batch = await client.create_batch() await prepare_accounts(client=client, log=log, branch=branch, batch=batch) + async for node, _ in batch.execute(): + log.info(f"- Created {node._schema.kind} - {node.name.value}") + + batch = await client.create_batch() + await map_permissions_to_roles(client=client, log=log, branch=branch, batch=batch) + async for node, _ in batch.execute(): + log.info(f"- Updated {node._schema.kind} - {node.name.value} with permissions") + + batch = await client.create_batch() + await map_user_and_roles_to_groups(client=client, log=log, branch=branch, batch=batch) + async for node, _ in batch.execute(): + log.info(f"- Updated {node._schema.kind} - {node.name.value} with roles and members") + await prepare_groups(client=client, log=log, branch=branch, batch=batch) await prepare_platforms(client=client, log=log, branch=branch, batch=batch) await prepare_organizations(client=client, log=log, branch=branch, batch=batch)