-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
1cfbeba
commit e34c497
Showing
11 changed files
with
478 additions
and
41 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,9 +3,7 @@ build-backend = "hatchling.build" | |
requires = ["hatchling"] | ||
|
||
[project] | ||
authors = [ | ||
{name = "Josh Thomas", email = "[email protected]"} | ||
] | ||
authors = [{ name = "Josh Thomas", email = "[email protected]" }] | ||
classifiers = [ | ||
"Development Status :: 3 - Alpha", | ||
"Framework :: Django", | ||
|
@@ -21,15 +19,13 @@ classifiers = [ | |
"Programming Language :: Python :: 3.10", | ||
"Programming Language :: Python :: 3.11", | ||
"Programming Language :: Python :: 3.12", | ||
"Programming Language :: Python :: Implementation :: CPython" | ||
] | ||
dependencies = [ | ||
"django>=4.2" | ||
"Programming Language :: Python :: Implementation :: CPython", | ||
] | ||
dependencies = ["django>=4.2"] | ||
description = "A custom Django field for storing and securely accessing a 1Password vault item." | ||
dynamic = ["version"] | ||
keywords = [] | ||
license = {file = "LICENSE"} | ||
license = { file = "LICENSE" } | ||
name = "django-opfield" | ||
readme = "README.md" | ||
requires-python = ">=3.8" | ||
|
@@ -52,7 +48,7 @@ dev = [ | |
"pytest-django", | ||
"pytest-randomly", | ||
"pytest-reverse", | ||
"pytest-xdist" | ||
"pytest-xdist", | ||
] | ||
docs = [ | ||
"cogapp", | ||
|
@@ -61,7 +57,7 @@ docs = [ | |
"sphinx", | ||
"sphinx-autobuild", | ||
"sphinx-copybutton", | ||
"sphinx-inline-tabs" | ||
"sphinx-inline-tabs", | ||
] | ||
lint = ["pre-commit"] | ||
|
||
|
@@ -74,20 +70,14 @@ Source = "https://github.com/westerveltco/django-opfield" | |
commit = true | ||
commit_message = ":bookmark: bump version {old_version} -> {new_version}" | ||
current_version = "0.1.0" | ||
push = false # set to false for CI | ||
push = false # set to false for CI | ||
tag = false | ||
version_pattern = "MAJOR.MINOR.PATCH[PYTAGNUM]" | ||
|
||
[tool.bumpver.file_patterns] | ||
".copier/package.yml" = [ | ||
'current_version: {version}' | ||
] | ||
"src/django_opfield/__init__.py" = [ | ||
'__version__ = "{version}"' | ||
] | ||
"tests/test_version.py" = [ | ||
'assert __version__ == "{version}"' | ||
] | ||
".copier/package.yml" = ['current_version: {version}'] | ||
"src/django_opfield/__init__.py" = ['__version__ = "{version}"'] | ||
"tests/test_version.py" = ['assert __version__ == "{version}"'] | ||
|
||
[tool.coverage.paths] | ||
source = ["src"] | ||
|
@@ -99,15 +89,12 @@ exclude_lines = [ | |
"if not DEBUG:", | ||
"if settings.DEBUG:", | ||
"if TYPE_CHECKING:", | ||
'def __str__\(self\)\s?\-?\>?\s?\w*\:' | ||
'def __str__\(self\)\s?\-?\>?\s?\w*\:', | ||
] | ||
fail_under = 75 | ||
|
||
[tool.coverage.run] | ||
omit = [ | ||
"src/django_opfield/migrations/*", | ||
"tests/*" | ||
] | ||
omit = ["src/django_opfield/migrations/*", "tests/*"] | ||
source = ["django_opfield"] | ||
|
||
[tool.django-stubs] | ||
|
@@ -118,10 +105,7 @@ strict_settings = false | |
indent = 2 | ||
|
||
[tool.hatch.build] | ||
exclude = [ | ||
".*", | ||
"Justfile" | ||
] | ||
exclude = [".*", "Justfile"] | ||
|
||
[tool.hatch.build.targets.wheel] | ||
packages = ["src/django_opfield"] | ||
|
@@ -134,20 +118,15 @@ check_untyped_defs = true | |
exclude = "docs/.*\\.py$" | ||
mypy_path = "src/" | ||
no_implicit_optional = true | ||
plugins = [ | ||
"mypy_django_plugin.main" | ||
] | ||
plugins = ["mypy_django_plugin.main"] | ||
warn_redundant_casts = true | ||
warn_unused_configs = true | ||
warn_unused_ignores = true | ||
|
||
[[tool.mypy.overrides]] | ||
ignore_errors = true | ||
ignore_missing_imports = true | ||
module = [ | ||
"django_opfield.*.migrations.*", | ||
"tests.*" | ||
] | ||
module = ["django_opfield.*.migrations.*", "tests.*"] | ||
|
||
[tool.mypy_django_plugin] | ||
ignore_missing_model_attributes = true | ||
|
@@ -180,7 +159,7 @@ exclude = [ | |
"dist", | ||
"migrations", | ||
"node_modules", | ||
"venv" | ||
"venv", | ||
] | ||
extend-include = ["*.pyi?"] | ||
indent-width = 4 | ||
|
@@ -202,13 +181,13 @@ quote-style = "double" | |
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" | ||
# Allow autofix for all enabled rules (when `--fix`) is provided. | ||
fixable = ["A", "B", "C", "D", "E", "F", "I"] | ||
ignore = ["E501", "E741"] # temporary | ||
ignore = ["E501", "E741"] # temporary | ||
select = [ | ||
"B", # flake8-bugbear | ||
"E", # Pycodestyle | ||
"F", # Pyflakes | ||
"I", # isort | ||
"UP" # pyupgrade | ||
"UP", # pyupgrade | ||
] | ||
unfixable = [] | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from __future__ import annotations | ||
|
||
from django.apps import AppConfig | ||
|
||
|
||
class DjangoOPFieldConfig(AppConfig): | ||
name = "django_opfield" | ||
label = "django_opfield" | ||
verbose_name = "Django OPField" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
from __future__ import annotations | ||
|
||
import os | ||
import sys | ||
from dataclasses import dataclass | ||
from typing import Any | ||
|
||
from django.conf import settings | ||
|
||
if sys.version_info >= (3, 12): | ||
from typing import override | ||
else: | ||
from typing_extensions import ( | ||
override, # pyright: ignore[reportUnreachable] # pragma: no cover | ||
) | ||
|
||
OPFIELD_SETTINGS_NAME = "DJANGO_OPFIELD" | ||
|
||
|
||
@dataclass(frozen=True) | ||
class AppSettings: | ||
@override | ||
def __getattribute__(self, __name: str) -> Any: | ||
user_settings = getattr(settings, OPFIELD_SETTINGS_NAME, {}) | ||
return user_settings.get(__name, super().__getattribute__(__name)) | ||
|
||
@property | ||
def OP_SERVICE_ACCOUNT_TOKEN(self) -> str: | ||
return os.environ.get("OP_SERVICE_ACCOUNT_TOKEN", "") | ||
|
||
|
||
app_settings = AppSettings() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
from __future__ import annotations | ||
|
||
import shutil | ||
import subprocess | ||
import sys | ||
from typing import Any | ||
|
||
from django.db import models | ||
|
||
from django_opfield.conf import app_settings | ||
from django_opfield.validators import OPURIValidator | ||
|
||
if sys.version_info >= (3, 12): | ||
from typing import override | ||
else: | ||
from typing_extensions import ( | ||
override, # pyright: ignore[reportUnreachable] # pragma: no cover | ||
) | ||
|
||
|
||
class OPField(models.CharField): | ||
description = "1Password secret" | ||
|
||
def __init__( | ||
self, vaults: list[str] | None = None, *args: Any, **kwargs: Any | ||
) -> None: | ||
self.vaults = vaults | ||
kwargs.setdefault("max_length", 255) | ||
super().__init__(*args, **kwargs) | ||
self.validators.append(OPURIValidator(vaults=self.vaults)) | ||
|
||
@classmethod | ||
def with_secret(cls, *args: Any, **kwargs: Any) -> tuple[OPField, property]: | ||
op_uri = cls(*args, **kwargs) | ||
|
||
@property | ||
def secret(self: models.Model) -> str | None: | ||
if not app_settings.OP_SERVICE_ACCOUNT_TOKEN: | ||
msg = "OP_SERVICE_ACCOUNT_TOKEN is not set" | ||
raise ValueError(msg) | ||
if shutil.which("op") is None: | ||
msg = "The 'op' CLI command is not available" | ||
raise OSError(msg) | ||
field_value = getattr(self, op_uri.name) | ||
result = subprocess.run(["op", "read", field_value], capture_output=True) | ||
if result.returncode != 0: | ||
err = result.stderr.decode("utf-8") | ||
msg = f"Could not read secret from 1Password: {err}" | ||
raise ValueError(msg) | ||
secret = result.stdout.decode("utf-8").strip() | ||
return secret | ||
|
||
@secret.setter | ||
def secret(self: models.Model, value: str) -> None: | ||
raise NotImplementedError("OPField does not support setting secret values") | ||
|
||
return op_uri, secret | ||
|
||
@override | ||
def deconstruct(self): | ||
name, path, args, kwargs = super().deconstruct() | ||
if self.vaults is not None: | ||
kwargs["vaults"] = self.vaults | ||
return name, path, args, kwargs |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
from __future__ import annotations | ||
|
||
import re | ||
import sys | ||
from typing import Any | ||
|
||
from django.core.exceptions import ValidationError | ||
from django.core.validators import RegexValidator | ||
from django.utils.deconstruct import deconstructible | ||
from django.utils.regex_helper import ( | ||
_lazy_re_compile, # pyright: ignore[reportPrivateUsage] | ||
) | ||
|
||
if sys.version_info >= (3, 12): | ||
from typing import override | ||
else: | ||
from typing_extensions import ( | ||
override, # pyright: ignore[reportUnreachable] # pragma: no cover | ||
) | ||
|
||
|
||
@deconstructible | ||
class OPURIValidator(RegexValidator): | ||
ul = "\u00a1-\uffff" # Unicode letters range (must not be a raw string). | ||
|
||
op_path_re = ( | ||
r"(?P<vault>[^/]+)" # Vault: must be a non-empty string without slashes | ||
r"/(?P<item>[^/]+)" # Item: must be a non-empty string without slashes | ||
r"(?:/(?P<section>[^/]+))?" # Section: optional, non-empty string without slashes | ||
r"/(?P<field>[^/]+)" # Field: must be a non-empty string without slashes | ||
) | ||
|
||
@property | ||
def op_regex(self) -> re.Pattern[str]: | ||
return _lazy_re_compile( | ||
r"^op://" + self.op_path_re + r"$", | ||
re.IGNORECASE, | ||
) | ||
|
||
message = "Enter a valid 1Password URI." | ||
schemes = ["op"] | ||
|
||
def __init__(self, vaults: list[str] | None = None, **kwargs: Any) -> None: | ||
super().__init__(**kwargs) | ||
self.vaults = vaults if vaults is not None else [] | ||
|
||
@override | ||
def __call__(self, value: Any) -> None: | ||
if not isinstance(value, str) or len(value) > 2048: | ||
raise ValidationError(self.message, code="invalid", params={"value": value}) | ||
|
||
# Match the URL against the regex to validate structure | ||
match = self.op_regex.match(value) | ||
if not match: | ||
raise ValidationError( | ||
self.message, code="invalid_format", params={"value": value} | ||
) | ||
|
||
# Check if the vault is in the list of valid vaults | ||
vault = match.group("vault") | ||
if self.vaults and vault not in self.vaults: | ||
raise ValidationError( | ||
f"The vault '{vault}' is not a valid vault.", code="invalid_vault" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
from __future__ import annotations | ||
|
||
import logging | ||
|
||
from django.conf import settings | ||
|
||
from .settings import DEFAULT_SETTINGS | ||
|
||
pytest_plugins = [] | ||
|
||
|
||
def pytest_configure(config): | ||
logging.disable(logging.CRITICAL) | ||
|
||
settings.configure(**DEFAULT_SETTINGS, **TEST_SETTINGS) | ||
|
||
|
||
TEST_SETTINGS = { | ||
"INSTALLED_APPS": [ | ||
"tests", | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from __future__ import annotations | ||
|
||
from django.db import models | ||
|
||
from django_opfield.fields import OPField | ||
|
||
|
||
class TestModel(models.Model): | ||
op_uri, op_secret = OPField.with_secret() |
Oops, something went wrong.