Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# netbox-oidc-group-sync

A [python-social-auth](https://github.com/python-social-auth/social-core) pipeline step that syncs NetBox
Django groups and `is_superuser`/`is_staff` status from an OIDC `groups` claim.
Django groups and `is_superuser` status from an OIDC `groups` claim.

## Why this exists

Expand Down Expand Up @@ -64,7 +64,7 @@ already set:
| `REMOTE_AUTH_GROUP_SYNC_ENABLED` | Master on/off switch. `sync_groups` no-ops entirely when falsy. |
| `REMOTE_AUTH_GROUP_HEADER` | The key to look up in the OIDC claims/userinfo `response` dict for the user's group list. Named for its original HTTP-header use case; repurposed here as a claim key, which doesn't conflict with anything since it has no effect on social-auth logins upstream. |
| `REMOTE_AUTH_AUTO_CREATE_GROUPS` | Create a Django `Group` for a claimed group name that doesn't exist yet, instead of skipping it with a logged error. |
| `REMOTE_AUTH_SUPERUSER_GROUPS` | Group names that grant `is_superuser`/`is_staff` when present in the user's synced claim groups. |
| `REMOTE_AUTH_SUPERUSER_GROUPS` | Group names that grant `is_superuser` when present in the user's synced claim groups. |
| `REMOTE_AUTH_SUPERUSERS` | Usernames that are always superusers, regardless of group membership. |

Group membership is a **full sync**, not additive: a user's Django groups are set to exactly what the claim
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ would set -- so there's nothing new to learn if you've configured NetBox's remot
| `REMOTE_AUTH_GROUP_SYNC_ENABLED` | `bool` | Master on/off switch. `sync_groups` no-ops entirely when falsy -- no group changes, no superuser changes. |
| `REMOTE_AUTH_GROUP_HEADER` | `str` | The key to look up in the OIDC claims/userinfo `response` dict for the user's group list. Named for its original HTTP-header use case in `RemoteUserBackend`; repurposed here as a claim key. This doesn't conflict with anything, since the setting has no effect on social-auth logins upstream. |
| `REMOTE_AUTH_AUTO_CREATE_GROUPS` | `bool` | When `true`, a claimed group name that doesn't exist yet is created. When `false`, it's skipped with a logged error and the user isn't added to it. |
| `REMOTE_AUTH_SUPERUSER_GROUPS` | `list[str]` | Group names that grant `is_superuser` and `is_staff` when present among the user's synced claim groups. |
| `REMOTE_AUTH_SUPERUSER_GROUPS` | `list[str]` | Group names that grant `is_superuser` when present among the user's synced claim groups. |
| `REMOTE_AUTH_SUPERUSERS` | `list[str]` | Usernames that are always superusers, regardless of group membership. |

## Semantics
Expand Down Expand Up @@ -38,5 +38,5 @@ REMOTE_AUTH_SUPERUSER_GROUPS = ["netbox-admins"]

A user in Authentik's `netbox-admins` group logs in, `sync_groups` runs as part of
[the pipeline](installation.md), NetBox creates a Django `netbox-admins` group if it doesn't already exist,
adds the user to it, and sets `is_superuser = True` / `is_staff = True`. A user removed from that group in
adds the user to it, and sets `is_superuser = True`. A user removed from that group in
Authentik loses superuser status the next time they log in.
11 changes: 10 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# netbox-oidc-group-sync

A [python-social-auth](https://github.com/python-social-auth/social-core) pipeline step that syncs NetBox
Django groups and `is_superuser`/`is_staff` status from an OIDC `groups` claim.
Django groups and `is_superuser` status from an OIDC `groups` claim.

## The gap

Expand Down Expand Up @@ -33,3 +33,12 @@ configuration surface, no fork of NetBox itself.

See [Installation](installation.md) to get it running, and [Configuration](configuration.md) for the settings
it reads.

## A NetBox-specific gotcha

NetBox 4.x doesn't use Django's stock `django.contrib.auth.models.Group` -- it defines its own
`users.models.Group`, a completely separate model/table, and `User.groups` points there instead.
`sync_groups` imports from `users.models`, not `django.contrib.auth.models`; getting this wrong produces a
`TypeError: Field 'id' expected a number but got <Group: ...>` at login time, since Django's M2M machinery
can't resolve a pk from an instance of the wrong model. Also unlike Django's stock `auth.Group`-based
`AbstractUser`, NetBox's `User` model has no `is_staff` field at all -- only `is_superuser`.
13 changes: 11 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ typecheck = "mypy src"

[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.django_settings"
pythonpath = ["."]
pythonpath = [".", "testapp"]
testpaths = ["tests"]
addopts = "--no-migrations"

# ---------------------------------------------------------------------------
# Coverage
Expand Down Expand Up @@ -110,6 +111,13 @@ warn_unreachable = true
module = "django.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
# users.models is NetBox's own internal app (its custom Group/User models,
# not Django's stock auth ones) -- only resolvable inside a real NetBox
# installation, so no stubs exist for it standalone.
module = "users.*"
ignore_missing_imports = true

# ---------------------------------------------------------------------------
# Ruff
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -141,7 +149,8 @@ ignore = [
convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D"] # no docstring requirements in tests
"tests/**" = ["D"] # no docstring requirements in tests
"testapp/**" = ["D"] # test-only stand-in for NetBox's users app, not public API

[tool.ruff.lint.isort]
known-first-party = ["netbox_oidc_group_sync"]
Expand Down
2 changes: 1 addition & 1 deletion src/netbox_oidc_group_sync/__version__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single-source version, read dynamically by hatchling's build backend."""

__version__ = "0.1.0"
__version__ = "0.1.1"
9 changes: 7 additions & 2 deletions src/netbox_oidc_group_sync/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@
from typing import Any

from django.conf import settings
from django.contrib.auth.models import Group

# NetBox 4.x doesn't use Django's stock auth.Group at all -- it defines its own
# users.models.Group (a separate model/table; User.groups points there, not at
# django.contrib.auth.models.Group). Importing the wrong one produces instances
# NetBox's own User.groups field doesn't recognize (Django raises "Field 'id'
# expected a number but got <Group: ...>" when you try to .set() them).
from users.models import Group

logger = logging.getLogger("netbox_oidc_group_sync.pipeline")

Expand Down Expand Up @@ -99,5 +105,4 @@ def sync_groups(
user.is_superuser = user.username in superusers or bool(
{group.name for group in group_list} & superuser_groups
)
user.is_staff = user.is_superuser
user.save()
Empty file added testapp/users/__init__.py
Empty file.
43 changes: 43 additions & 0 deletions testapp/users/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""A minimal stand-in for NetBox's real `users` app, used only in tests.

Mirrors the parts of NetBox 4.x's actual `users.models.Group`/`users.models.User`
shape that `netbox_oidc_group_sync.pipeline.sync_groups` depends on: `Group` is
a standalone model (NOT `django.contrib.auth.models.Group`), and `User.groups`
is a `ManyToManyField` pointing at it. Getting this wrong in the real package
is exactly the bug this test app exists to catch.
"""

from typing import ClassVar

from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin
from django.db import models


class Group(models.Model):
name = models.CharField(max_length=150, unique=True)

class Meta:
app_label = "users"

def __str__(self) -> str:
return self.name


class User(AbstractBaseUser, PermissionsMixin):
username = models.CharField(max_length=150, unique=True)
email = models.EmailField(blank=True)
is_active = models.BooleanField(default=True)

groups = models.ManyToManyField(
to="users.Group",
blank=True,
related_name="users",
related_query_name="user",
)

USERNAME_FIELD = "username"
REQUIRED_FIELDS: ClassVar[list[str]] = []

class Meta:
app_label = "users"
3 changes: 3 additions & 0 deletions tests/django_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"users",
]

AUTH_USER_MODEL = "users.User"

DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
Expand Down
6 changes: 1 addition & 5 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest
from django.contrib.auth.models import Group, User
from pytest_django.fixtures import Settings
from users.models import Group, User

from netbox_oidc_group_sync.pipeline import sync_groups

Expand Down Expand Up @@ -41,7 +41,6 @@ def test_assigns_existing_groups(user: User) -> None:
user.refresh_from_db()
assert [g.name for g in user.groups.all()] == ["netbox-users"]
assert user.is_superuser is False
assert user.is_staff is False


@pytest.mark.django_db
Expand Down Expand Up @@ -76,7 +75,6 @@ def test_grants_superuser_for_matching_group(settings: Settings, user: User) ->

user.refresh_from_db()
assert user.is_superuser is True
assert user.is_staff is True


@pytest.mark.django_db
Expand All @@ -93,15 +91,13 @@ def test_grants_superuser_for_username_allowlist(settings: Settings, user: User)
@pytest.mark.django_db
def test_revokes_superuser_when_no_longer_in_group(settings: Settings, user: User) -> None:
user.is_superuser = True
user.is_staff = True
user.save()
settings.REMOTE_AUTH_SUPERUSER_GROUPS = ["netbox-admins"]

sync_groups(backend=None, user=user, response={"groups": []})

user.refresh_from_db()
assert user.is_superuser is False
assert user.is_staff is False


@pytest.mark.django_db
Expand Down
Loading