diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 3e7f38e..94d110d 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml
new file mode 100644
index 0000000..7344985
--- /dev/null
+++ b/.github/workflows/pr-tests.yml
@@ -0,0 +1,127 @@
+name: PR Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: pr-tests-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ django-tests:
+ name: Django PR tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ permissions:
+ contents: read
+
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ env:
+ DJANGO_SETTINGS_MODULE: bigvince.settings_
+ AUTH_BACKEND_MODE: local
+ DEBUG: "True"
+ VINCE_DB_SSL_MODE: disable
+ # SECRET_KEY and GOOGLE_RECAPTCHA_SECRET_KEY are generated ephemerally
+ # in the "Generate ephemeral test secrets" step below.
+ GOOGLE_SITE_KEY: test-site-key
+ VINCE_DEV_SYSTEM: "1"
+ VINCE_TRACK_DB_HOST: 127.0.0.1
+ VINCE_TRACK_DB_PORT: "5432"
+ VINCE_TRACK_DB_USER: vince
+ VINCE_TRACK_DB_PASS: vince
+ VINCE_TRACK_DB_NAME: vincetest
+ VINCE_COMM_DB_HOST: 127.0.0.1
+ VINCE_COMM_DB_PORT: "5432"
+ VINCE_COMM_DB_USER: vince
+ VINCE_COMM_DB_PASS: vince
+ VINCE_COMM_DB_NAME: vincecommtest
+ VINCE_PUB_DB_HOST: 127.0.0.1
+ VINCE_PUB_DB_PORT: "5432"
+ VINCE_PUB_DB_USER: vince
+ VINCE_PUB_DB_PASS: vince
+ VINCE_PUB_DB_NAME: vincepubtest
+ NO_REPLY_EMAIL: noreply@example.com
+ REPLY_EMAIL: support@example.com
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Generate ephemeral test secrets
+ run: |
+ # Use Python stdlib (no Django needed yet) to generate a 50-char
+ # URL-safe random string. These values are thrown away after the run.
+ echo "SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" >> $GITHUB_ENV
+ echo "GOOGLE_RECAPTCHA_SECRET_KEY=ci-ephemeral-recaptcha-key" >> $GITHUB_ENV
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+ cache: pip
+
+ - name: Install OS dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y postgresql-client swig libpq-dev libssl-dev
+
+ - name: Install Python build tools
+ run: pip install --upgrade wheel cython
+
+ - name: Install dependencies
+ run: pip install -r requirements.txt
+
+ - name: Create application databases
+ env:
+ PGPASSWORD: postgres
+ run: |
+ psql -h 127.0.0.1 -U postgres -d postgres <<'SQL'
+ DO $$
+ BEGIN
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'vince') THEN
+ CREATE ROLE vince LOGIN PASSWORD 'vince' CREATEDB;
+ END IF;
+ END
+ $$;
+ SELECT 'CREATE DATABASE vincetest OWNER vince'
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'vincetest')\gexec
+ SELECT 'CREATE DATABASE vincecommtest OWNER vince'
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'vincecommtest')\gexec
+ SELECT 'CREATE DATABASE vincepubtest OWNER vince'
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'vincepubtest')\gexec
+ SQL
+
+ - name: Clean-slate migrations and regenerate
+ run: |
+ for d in */migrations/*.py; do
+ if [ "$(basename "$d")" != "__init__.py" ]; then
+ rm -f "$d"
+ fi
+ done
+
+ python -W ignore manage.py makemigrations
+
+ - name: Run migrations
+ run: |
+ python manage.py migrate
+ python manage.py migrate --database=vincecomm
+ python manage.py migrate --database=vincepub
+
+ - name: Run all tests vince and vinny
+ run: python manage.py test vince vinny --verbosity=2
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44ae3ba..09a6c60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,27 @@ VINCE Coordination platform code
## Description
-VINCE Coordination platform
+VINCE Coordination platform
+
+Version 3.0.44 2026-08-12
+
+* dependabot update recommendations: `awscli` 1.44.38 to 1.45.28 with corresponding updates - `boto3` to 1.43.28, `botocore` to 1.43.28, `s3transfer` 0.16.0 to 0.18.0
+* Added GitHub workflow for `.github/workflows/pr-tests.yml` and a number of Test Cases
+* Added `vince/auth` override for local authentication to support IDAM and OpenID usage and building related Test Cases
+* CSAF intake capability is now introduced with the `CommVulReportAPIView` auto-detect Form or API JSON submission (GH-Issue #24 ongoing)
+* CSAF JSON view (using Ace Editor library ) supported to help adoption in Vulnerability Request form (VRF) submission using VRF profile of CSAF
+* dependabot update recommendations: `soupsieve` 2.3.2.post1 to 2.8.4, `pyasn1` 0.6.3 to 0.6.4, `cryptography` 48.0.1 to 50.0.0
+* added logging to address login loop issue (Internal-847)
+* modified regexes to avoid syntax error caused by Python 3.12 update (Internal-859)
+* disabled view for TCR API endpoint view (Internal-856)
+* proper fix for revisited CaseFilterResults View add Due Date in template vince/templates/vince/searchresults.html (Internal-849)
+* CVE-2026-18744 - poor permissions management in vinny/views.py: (GetStatementView) not checking member_id of user
+* CVE-2026-18749 - poor permissions management in vinny/views.py: (VinceAttachmentView type=track).
+ check if attachment is `shared` before allowing it.
+* CVE-2026-18750 - poor permissions management in vinny/views.py: (ModifyEmailNotifications).
+ Check the record's contact belongs to the requesting group-admin.
+* CSAFSerializer relaxed check on origin to start with "https://" instead of .find("https://") > -1
+* Updates of dependencies djangorestframework==3.15.2 redis==4.5.5 from vulnerability scan.
Version 3.0.43 2026-06-25
@@ -43,8 +63,8 @@ Version 3.0.40 2026-05-12
Version 3.0.39 2026-05-06
-* fixed issue preventing users from making lists and tables in VINCE Comm comments (VIN-845)
-* fixed bug reported by user affecting VINCE's vetting of incoming email ticket reports (VIN-846)
+* fixed issue preventing users from making lists and tables in VINCE Comm comments (Internal-845)
+* fixed bug reported by user affecting VINCE's vetting of incoming email ticket reports (Internal-846)
diff --git a/bigvince/settings_.py b/bigvince/settings_.py
index cc53e0c..633c5c6 100644
--- a/bigvince/settings_.py
+++ b/bigvince/settings_.py
@@ -54,7 +54,7 @@
ROOT_DIR = environ.Path(__file__) - 3
# any change that requires database migrations is a minor release
-VERSION = "3.0.43"
+VERSION = "3.0.44"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/
@@ -68,6 +68,20 @@
LOCALSTACK = os.environ.get("LOCALSTACK")
+# ---------------------------------------------------------------------------
+# Auth backend mode
+# ---------------------------------------------------------------------------
+# Set AUTH_BACKEND_MODE=local in .env (or the environment) to bypass Cognito
+# for local development and automated tests. The production default is
+# "cognito". Any other value raises a ValueError at startup so
+# misconfigurations are caught early.
+AUTH_BACKEND_MODE = os.getenv("AUTH_BACKEND_MODE", "cognito").lower()
+if AUTH_BACKEND_MODE not in {"cognito", "local"}:
+ raise ValueError(
+ f"Invalid AUTH_BACKEND_MODE={AUTH_BACKEND_MODE!r}. "
+ "Valid choices are 'cognito' (default) and 'local'."
+ )
+
TERMS_URL = os.environ.get(
"TERMS_URL", "https://docs.aws.amazon.com/cognito/latest/developerguide/data-protection.html"
)
@@ -393,6 +407,7 @@ def get_secret(secret_arn):
# Check environment variables for database credentials
else:
VINCE_NAMESPACE = "vince"
+ MFA_REDIRECT_URL = "vince:mfaauth"
SUPERUSER = {"username": "superuser@example.com", "password": "SavingTheWorldWithPerl"}
vincetrack_user = os.environ.get("VINCE_TRACK_DB_USER", "vincetrack")
vincetrack_password = os.environ.get("VINCE_TRACK_DB_PASS", "vincetrack")
@@ -417,7 +432,7 @@ def get_secret(secret_arn):
"HOST": os.environ.get("VINCE_TRACK_DB_HOST", "localhost"),
"PORT": os.environ.get("VINCE_TRACK_DB_PORT", 5432),
"OPTIONS": {
- "sslmode": "require",
+ "sslmode": os.environ.get("VINCE_DB_SSL_MODE", "require"),
},
"CONN_MAX_AGE": int(os.environ.get("DB_CONN_MAX_AGE", "60")),
}
@@ -482,9 +497,15 @@ def get_secret(secret_arn):
DATABASE_ROUTERS = ["vince.dbrouter.BigVinceRouter"]
-AUTHENTICATION_BACKENDS = [
- "cogauth.backend.CognitoAuthenticate",
-]
+if AUTH_BACKEND_MODE == "local":
+ # Local mode: plain Django model-based auth, no Cognito dependency.
+ AUTHENTICATION_BACKENDS = [
+ "django.contrib.auth.backends.ModelBackend",
+ ]
+else:
+ AUTHENTICATION_BACKENDS = [
+ "cogauth.backend.CognitoAuthenticate",
+ ]
# Cognito Settings - these can be found in the AWS Cognito Console.
# The user pool must be setup prior to deploying
@@ -514,10 +535,11 @@ def get_secret(secret_arn):
# "vincetrack" local group
COGNITO_VINCETRACK_GROUPS = os.environ.get("AWS_COGNITO_VINCETRACK_GROUPS", default=COGNITO_ADMIN_GROUP)
-# Any user in this group will automatically be promoted to superuser
-# Choose wisely - ideally this should be a more select set than the
-# VINCETrack group
-COGNITO_SUPERUSER_GROUP = os.environ.get("AWS_COGNITO_SUPERUSER_GROUP", COGNITO_ADMIN_GROUP)
+# If you uncomment this line ny user in this group will automatically
+# be promoted to superuser. Choose wisely - ideally this should be
+#a more select set than the VINCETrack group like "SUPERADMIN"
+#COGNITO_SUPERUSER_GROUP = os.environ.get("AWS_COGNITO_SUPERUSER_GROUP", COGNITO_ADMIN_GROUP)
+COGNITO_SUPERUSER_GROUP = os.environ.get("AWS_COGNITO_SUPERUSER_GROUP", "SADMIN")
# COGNITO_LIMITED_ACCESS_GROUPS can be used to give special permission to views
# in VINCECOMM
@@ -718,8 +740,12 @@ def get_secret(secret_arn):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"cogauth.backend.HashedTokenAuthentication",
- #'rest_framework.authentication.TokenAuthentication',
- # 'cogauth.backend.JSONWebTokenAuthentication',
+#If you want to support fallback in REST request from a browser
+#using a local authenticated session
+#with Local Auth uncomment the first and with Cognito uncomment
+#the second line
+ #"rest_framework.authentication.SessionAuthentication",
+ #"cogauth.backend.CognitoAuthenticateAPI",
],
"DEFAULT_THROTTLE_RATES": {
"user": "100/hour", # Authenticated users can make 100 requests per hour
diff --git a/bigvince/urls.py b/bigvince/urls.py
index 4aeee39..fb7266f 100644
--- a/bigvince/urls.py
+++ b/bigvince/urls.py
@@ -67,6 +67,7 @@
re_path('^vulfeed/?$', LatestVulReportActivity()),
path('vince/admin/', admin.site.urls),
path('vince/comm/', include(('vinny.urls', 'vinny'), namespace="vinny")),
+ path('vince/auth/', include(('vince.auth.urls', 'localauth'), namespace='localauth')),
path('vince/',include(('vince.urls', 'vince'), namespace="vince")),
path('vince/comm/auth/', include(('cogauth.urls', 'cogauth'), namespace='cogauth')),
path('vince/comm/admin/', vinnyadmin.urls),
diff --git a/cogauth/forms.py b/cogauth/forms.py
index a344fdf..378fe41 100644
--- a/cogauth/forms.py
+++ b/cogauth/forms.py
@@ -186,7 +186,7 @@ def clean(self):
# phone numbers vary greatly world-wide, so best we can do is verify
# that no "weird" characters are entered.
def validate_phone_number(value):
- phone_re = "\+[^0-9+]" # set of all things except phone number characters and commas
+ phone_re = r"\+[^0-9+]" # set of all things except phone number characters and commas
if search(phone_re, value) is not None:
raise forms.ValidationError("%s contains non-telephone characters" % value)
diff --git a/cogauth/templates/cogauth/profile.html b/cogauth/templates/cogauth/profile.html
index cfe59f0..15882ab 100644
--- a/cogauth/templates/cogauth/profile.html
+++ b/cogauth/templates/cogauth/profile.html
@@ -1,4 +1,4 @@
-{% extends VINCECOMM_BASE_TEMPLATE %}
+{% extends 'vinny/base.html' %}
{% load i18n static %}
{% block js %}
{{ block.super }}
@@ -123,7 +123,8 @@
{{ coguser.preferred_username }}
API Key
-
+
+
diff --git a/cogauth/utils.py b/cogauth/utils.py
index 0be8bca..d390cfc 100644
--- a/cogauth/utils.py
+++ b/cogauth/utils.py
@@ -276,6 +276,13 @@ def add_permissions(user):
def cognito_check_track_permissions(request):
logger.debug(f"=== cognito_check_track_permissions called for user {request.user.username if request.user.is_authenticated else 'ANONYMOUS'} ===")
+ if getattr(settings, "AUTH_BACKEND_MODE", None) == "local":
+ logger.debug(f"Bypass cognito checks for user {request.user.username} to local checks")
+ return (
+ request.user is not None
+ and request.user.is_authenticated
+ and request.user.is_active
+ )
old_user = False
access_token = request.session.get('ACCESS_TOKEN')
logger.debug(f"ACCESS_TOKEN present in session: {access_token is not None}")
diff --git a/cogauth/views.py b/cogauth/views.py
index 20209c8..98272b8 100644
--- a/cogauth/views.py
+++ b/cogauth/views.py
@@ -35,6 +35,7 @@
from django.utils.translation import gettext as _
from django.utils.decorators import method_decorator
from django.core.exceptions import PermissionDenied
+from django.contrib.auth import update_session_auth_hash
try:
from django.urls import reverse_lazy, reverse
@@ -144,11 +145,20 @@ class GetUserMixin(object):
cognito = None
def get_token_groups(self):
+ if getattr(settings, "AUTH_BACKEND_MODE", None) == "local":
+ return list(
+ self.request.user.groups.values_list(
+ "name",
+ flat=True,
+ )
+ )
if self.cognito is None:
self.cognito = get_cognito(self.request)
return get_group(self.request.session.get("ACCESS_TOKEN"))
def get_user(self):
+ if getattr(settings, "AUTH_BACKEND_MODE", None) == "local":
+ return self.request.user
if self.cognito is None:
self.cognito = get_cognito(self.request)
user = self.cognito.get_user(attr_map=settings.COGNITO_ATTR_MAPPING)
@@ -420,8 +430,9 @@ def get_context_data(self, **kwargs):
# identified by var vinny:deltoken
token = VinceAPIToken(user=self.request.user)
token.save(context["token"])
- c = get_cognito(self.request)
- c.update_profile({"custom:api_key": str(token)})
+ if getattr(settings, "AUTH_BACKEND_MODE", None) != "local":
+ c = get_cognito(self.request)
+ c.update_profile({"custom:api_key": str(token)})
ip = vinceutils.get_ip(self.request)
logger.debug(f"New API key generated for { self.request.user.username } from ip {ip}")
return context
@@ -591,7 +602,12 @@ def post(self, request, *args, **kwargs):
logger.debug(
f"Login success! Now checking permissions for user {self.request.user.username} - is authenticated ? {self.request.user.is_authenticated} "
)
- cognito_check_permissions(self.request)
+ if getattr(settings, "AUTH_BACKEND_MODE", None) != "local":
+ cognito_check_permissions(self.request)
+ elif user.is_active and user.is_authenticated:
+ logger.debug(f"Bypassing permissions checks to use local groups only for {user.username}")
+ else:
+ raise PermissionDenied("User is not active or not authorized")
return super().form_valid(form)
# return redirect("vinny:dashboard")
else:
@@ -796,6 +812,45 @@ class MFAAuthRequiredView(FormView, AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not (request.session.get("MFAREQUIRED") and request.session.get("username")):
+ # Diagnostics: the MFA session check just failed. Capture whether the
+ # session is genuinely empty or whether a FRESH direct DB read of the same
+ # session key can see data the request-cycle session could not. A mismatch here
+ # (request session empty, but fresh DB read populated) points at a stale/pooled
+ # DB connection serving an out-of-date read rather than truly-missing data.
+ # Safe to leave on: only runs on the failure branch, logs no secret values.
+ try:
+ from django.contrib.sessions.backends.db import SessionStore
+ from django.db import connections
+
+ session_key = request.session.session_key
+ cycle_keys = sorted(request.session.keys())
+
+ # Fresh read: bypass the request's already-loaded session object.
+ fresh_keys = None
+ fresh_exists = None
+ if session_key:
+ fresh_store = SessionStore(session_key=session_key)
+ fresh_data = fresh_store.load() # hits the DB again this request
+ fresh_exists = bool(fresh_data)
+ fresh_keys = sorted(fresh_data.keys())
+
+ # Which DB alias answered, and was the connection reused (pooled) or new?
+ alias = "default"
+ conn = connections[alias]
+ conn_reused = not getattr(conn, "connection", None) is None
+
+ logger.debug(
+ "MFA-session-miss diagnostics: "
+ f"session_key={session_key!r}, request_cycle_keys={cycle_keys}, "
+ f"fresh_db_read_exists={fresh_exists}, fresh_db_read_keys={fresh_keys}, "
+ f"db_alias={alias}, conn_max_age={conn.settings_dict.get('CONN_MAX_AGE')}, "
+ f"conn_health_checks={conn.settings_dict.get('CONN_HEALTH_CHECKS')}, "
+ f"connection_reused={conn_reused}, path={request.path}, "
+ f"referer={request.META.get('HTTP_REFERER', '')!r}"
+ )
+ except Exception as diag_err:
+ logger.debug(f"MFA-session-miss diagnostics failed to run: {diag_err}")
+
# Check for potential redirect loop: if user came from login page and is trying
# to access MFA page, but session check failed, redirect to dashboard instead
# of creating a loop where next=/mfa/
@@ -1016,6 +1071,13 @@ def get_success_url(self):
def form_valid(self, form):
# user = form.save()
# update_session_auth_hash(self.request, user)
+ if getattr(settings, "AUTH_BACKEND_MODE", None) == "local":
+ user = self.request.user
+ user.set_password(form.cleaned_data["new_password1"])
+ user.save()
+ update_session_auth_hash(self.request, user)
+ send_courtesy_email("password_change", self.request.user)
+ return super().form_valid(form)
c = get_cognito(self.request)
ip = vinceutils.get_ip(self.request)
@@ -1291,7 +1353,11 @@ def dispatch(self, request, *args, **kwargs):
class GetCognitoUserMixin(object):
- client = boto3.client("apigateway", region_name=settings.COGNITO_REGION, endpoint_url=get_cognito_url())
+ client = (
+ boto3.client("apigateway", region_name=settings.COGNITO_REGION, endpoint_url=get_cognito_url())
+ if settings.COGNITO_REGION
+ else None
+ )
def get_user_object(self):
cog_client = boto3.client("cognito-idp", endpoint_url=get_cognito_url(), region=settings.COGNITO_REGION)
diff --git a/requirements.txt b/requirements.txt
index 7fef6cc..5d383cf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,15 +4,15 @@ asgiref==3.6.0
asn1crypto==1.5.1
async-timeout==4.0.2
attrs==22.1.0
-awscli==1.44.38
+awscli==1.45.28
backports.zoneinfo;python_version<"3.9"
beautifulsoup4==4.11.1
billiard==4.0.2
bleach==6.4.0
bleach-whitelist==0.0.11
boto==2.49.0
-boto3==1.42.48
-botocore==1.42.48
+boto3==1.43.28
+botocore==1.43.28
cached-property==1.5.2
certifi==2024.7.4
cffi==2.0.0
@@ -20,7 +20,7 @@ chardet==5.0.0
charset-normalizer==2.1.1
click==8.1.3
colorama==0.4.4
-cryptography==48.0.1
+cryptography==50.0.0
cvelib==1.3.0
Deprecated==1.2.13
dictdiffer==0.9.0
@@ -33,7 +33,7 @@ django-qr-code==3.1.1
django-ses==3.5.0
django-storages==1.13.1
django-widget-tweaks==1.4.12
-djangorestframework==3.14.0
+djangorestframework==3.15.2
docutils==0.18.1
ecdsa==0.19.2
envs==1.4
@@ -56,7 +56,7 @@ pip-autoremove==0.10.0
pkgutil-resolve-name==1.3.10
psycopg2==2.9.9
psycopg2-binary==2.9.5
-pyasn1==0.6.3
+pyasn1==0.6.4
pycparser==2.21
pycryptodome==3.19.1
pydantic==1.10.13
@@ -69,15 +69,15 @@ python-jose==3.5.0
pytz==2022.6
PyYAML==6.0.1
qrcode==7.3.1
-redis==4.5.4
+redis==4.5.5
requests==2.33.0
rsa==4.7.2
-s3transfer==0.16.0
+s3transfer==0.18.0
segno==1.5.2
setuptools>=65.0.0,<81
simplejson==3.18.0
six==1.16.0
-soupsieve==2.3.2.post1
+soupsieve==2.8.4
sqlparse==0.5.4
typing-extensions>=4.9.0
urllib3==2.7.0
diff --git a/vince/auth/__init__.py b/vince/auth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vince/auth/adapters.py b/vince/auth/adapters.py
new file mode 100644
index 0000000..8e5af91
--- /dev/null
+++ b/vince/auth/adapters.py
@@ -0,0 +1,173 @@
+#########################################################################
+# VINCE
+#
+# Copyright 2023 Carnegie Mellon University.
+#
+# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE ENGINEERING
+# INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. CARNEGIE MELLON
+# UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, EITHER EXPRESSED OR IMPLIED,
+# AS TO ANY MATTER INCLUDING, BUT NOT LIMITED TO, WARRANTY OF FITNESS FOR
+# PURPOSE OR MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE OF THE
+# MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE ANY WARRANTY OF ANY KIND
+# WITH RESPECT TO FREEDOM FROM PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT.
+#
+# Released under a MIT (SEI)-style license, please see license.txt or contact
+# permission@sei.cmu.edu for full terms.
+#
+# DM21-1126
+########################################################################
+"""
+Auth adapter abstraction for VINCE.
+
+Provides a thin interface over authentication backends so callers can
+switch between Cognito (production default) and a local Django-session
+mode (for development/testing) via the AUTH_BACKEND_MODE setting.
+
+Usage::
+
+ from vince.auth.adapters import get_auth_adapter
+ adapter = get_auth_adapter()
+ user = adapter.authenticate_request(request)
+"""
+
+import logging
+from dataclasses import dataclass, field
+from typing import Iterable, Optional
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.core.exceptions import ImproperlyConfigured
+
+logger = logging.getLogger(__name__)
+
+User = get_user_model()
+
+
+@dataclass
+class Identity:
+ """Portable identity representation shared across adapters."""
+
+ username: str
+ email: Optional[str] = None
+ groups: Optional[Iterable[str]] = field(default=None)
+
+
+class BaseAuthAdapter:
+ """Common interface that all auth adapters must implement."""
+
+ def authenticate_request(self, request):
+ """Return an authenticated User for *request*, or ``None``."""
+ raise NotImplementedError
+
+ def sync_user(self, identity: Identity):
+ """Ensure a local User matching *identity* exists and return it."""
+ raise NotImplementedError
+
+ def get_roles(self, user) -> set:
+ """Return the set of group names the user belongs to."""
+ return set(user.groups.values_list("name", flat=True))
+
+
+class LocalAuthAdapter(BaseAuthAdapter):
+ """
+ Local-first auth adapter for development and testing.
+
+ Authentication priority:
+ 1. If ``request.user`` is already authenticated (e.g. via Django session),
+ return it as-is.
+ 2. If ``settings.DEBUG`` is ``True``, read the optional dev bootstrap
+ headers ``X-Dev-User``, ``X-Dev-Email``, and ``X-Dev-Groups`` to
+ auto-create / sync a local user on-the-fly.
+
+ The header bootstrap is **strictly** limited to ``DEBUG=True`` so it
+ can never be exploited in production.
+ """
+
+ DEV_USER_HEADER = "HTTP_X_DEV_USER"
+ DEV_EMAIL_HEADER = "HTTP_X_DEV_EMAIL"
+ DEV_GROUPS_HEADER = "HTTP_X_DEV_GROUPS"
+
+ def authenticate_request(self, request):
+ # 1) Trust an already-authenticated Django session user.
+ user = getattr(request, "user", None)
+ if user is not None and user.is_authenticated:
+ return user
+
+ # 2) Dev-header bootstrap (DEBUG-only guard).
+ if not getattr(settings, "DEBUG", False):
+ return None
+
+ username = request.META.get(self.DEV_USER_HEADER)
+ if not username:
+ return None
+
+ email = request.META.get(self.DEV_EMAIL_HEADER) or ""
+ raw_groups = request.META.get(self.DEV_GROUPS_HEADER, "")
+ groups = [g.strip() for g in raw_groups.split(",") if g.strip()]
+
+ logger.debug("LocalAuthAdapter: bootstrapping dev user %r from request headers", username)
+ identity = Identity(username=username, email=email, groups=groups)
+ return self.sync_user(identity)
+
+ def sync_user(self, identity: Identity):
+ """Get-or-create a local User and sync email / groups."""
+ user, created = User.objects.get_or_create(
+ username=identity.username,
+ defaults={"email": identity.email or ""},
+ )
+ if created:
+ logger.debug("LocalAuthAdapter: created local user %r", identity.username)
+
+ if identity.email and user.email != identity.email:
+ user.email = identity.email
+ user.save(update_fields=["email"])
+
+ if identity.groups is not None:
+ existing = set(user.groups.values_list("name", flat=True))
+ for group_name in set(identity.groups) - existing:
+ group, _ = Group.objects.get_or_create(name=group_name)
+ user.groups.add(group)
+
+ return user
+
+
+class CognitoAuthAdapter(BaseAuthAdapter):
+ """
+ Thin wrapper around existing Cognito auth behaviour.
+
+ This adapter deliberately delegates to the already-authenticated
+ ``request.user`` that the ``CognitoAuthenticate`` backend populates
+ via Django's ``AuthenticationMiddleware``. All Cognito-specific logic
+ remains in ``cogauth.backend`` and is untouched.
+ """
+
+ def authenticate_request(self, request):
+ user = getattr(request, "user", None)
+ if user is not None and user.is_authenticated:
+ return user
+ return None
+
+ def sync_user(self, identity: Identity):
+ user, _ = User.objects.get_or_create(
+ username=identity.username,
+ defaults={"email": identity.email or ""},
+ )
+ return user
+
+
+def get_auth_adapter() -> BaseAuthAdapter:
+ """
+ Factory that returns the adapter matching ``settings.AUTH_BACKEND_MODE``.
+
+ Valid values: ``"cognito"`` (default) | ``"local"``.
+ Raises ``ImproperlyConfigured`` for any other value.
+ """
+ mode = getattr(settings, "AUTH_BACKEND_MODE", "cognito").lower()
+ if mode == "local":
+ return LocalAuthAdapter()
+ if mode == "cognito":
+ return CognitoAuthAdapter()
+ raise ImproperlyConfigured(
+ f"Unsupported AUTH_BACKEND_MODE={mode!r}. Valid choices are 'cognito' and 'local'."
+ )
diff --git a/vince/auth/service.py b/vince/auth/service.py
new file mode 100644
index 0000000..85bead1
--- /dev/null
+++ b/vince/auth/service.py
@@ -0,0 +1,58 @@
+#########################################################################
+# VINCE
+#
+# Copyright 2023 Carnegie Mellon University.
+#
+# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE ENGINEERING
+# INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. CARNEGIE MELLON
+# UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, EITHER EXPRESSED OR IMPLIED,
+# AS TO ANY MATTER INCLUDING, BUT NOT LIMITED TO, WARRANTY OF FITNESS FOR
+# PURPOSE OR MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE OF THE
+# MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE ANY WARRANTY OF ANY KIND
+# WITH RESPECT TO FREEDOM FROM PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT.
+#
+# Released under a MIT (SEI)-style license, please see license.txt or contact
+# permission@sei.cmu.edu for full terms.
+#
+# DM21-1126
+########################################################################
+"""
+Thin service bridge for auth operations.
+
+Centralises call-sites so views and middleware only import from here
+instead of referencing the adapters directly. This minimises the
+surface area that needs to change when swapping auth modes.
+
+Example::
+
+ from vince.auth.service import authenticate_request, get_roles
+
+ user = authenticate_request(request)
+ roles = get_roles(user)
+"""
+
+from .adapters import Identity, get_auth_adapter
+
+
+def authenticate_request(request):
+ """
+ Return an authenticated User for *request* using the configured adapter,
+ or ``None`` if authentication cannot be established.
+ """
+ return get_auth_adapter().authenticate_request(request)
+
+
+def sync_user(identity: Identity):
+ """
+ Ensure a local User record for *identity* exists and return it.
+
+ Delegates to the configured adapter's ``sync_user`` implementation.
+ """
+ return get_auth_adapter().sync_user(identity)
+
+
+def get_roles(user) -> set:
+ """
+ Return the set of Django Group names the given *user* belongs to.
+ """
+ return get_auth_adapter().get_roles(user)
diff --git a/vince/auth/tests/__init__.py b/vince/auth/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/vince/auth/tests/test_local_auth_smoke.py b/vince/auth/tests/test_local_auth_smoke.py
new file mode 100644
index 0000000..af6aa3c
--- /dev/null
+++ b/vince/auth/tests/test_local_auth_smoke.py
@@ -0,0 +1,264 @@
+#########################################################################
+# VINCE
+#
+# Copyright 2023 Carnegie Mellon University.
+#
+# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE ENGINEERING
+# INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. CARNEGIE MELLON
+# UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, EITHER EXPRESSED OR IMPLIED,
+# AS TO ANY MATTER INCLUDING, BUT NOT LIMITED TO, WARRANTY OF FITNESS FOR
+# PURPOSE OR MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE OF THE
+# MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE ANY WARRANTY OF ANY KIND
+# WITH RESPECT TO FREEDOM FROM PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT.
+#
+# Released under a MIT (SEI)-style license, please see license.txt or contact
+# permission@sei.cmu.edu for full terms.
+#
+# DM21-1126
+########################################################################
+"""
+Smoke tests for the local auth mode (AUTH_BACKEND_MODE=local).
+
+These tests use RequestFactory so they are self-contained and do not
+depend on any URL configuration or external services.
+"""
+
+import json
+
+from django.contrib.auth.models import AnonymousUser, User
+from django.test import RequestFactory, TestCase, override_settings
+
+from vince.auth.adapters import Identity, LocalAuthAdapter, get_auth_adapter
+from vince.auth.views import whoami
+
+
+@override_settings(DEBUG=True, AUTH_BACKEND_MODE="local")
+class LocalAuthAdapterTest(TestCase):
+ """Unit tests for LocalAuthAdapter behaviour."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+ self.adapter = LocalAuthAdapter()
+
+ # ------------------------------------------------------------------
+ # authenticate_request – already-authenticated user is returned as-is
+ # ------------------------------------------------------------------
+ def test_returns_existing_authenticated_user(self):
+ request = self.factory.get("/")
+ user = User.objects.create_user(username="existing")
+ request.user = user
+ result = self.adapter.authenticate_request(request)
+ self.assertEqual(result, user)
+
+ # ------------------------------------------------------------------
+ # authenticate_request – dev headers create a new local user
+ # ------------------------------------------------------------------
+ def test_dev_headers_create_user(self):
+ request = self.factory.get(
+ "/",
+ **{
+ "HTTP_X_DEV_USER": "devuser",
+ "HTTP_X_DEV_EMAIL": "dev@example.com",
+ "HTTP_X_DEV_GROUPS": "vince_admin,analyst",
+ },
+ )
+ request.user = AnonymousUser()
+ result = self.adapter.authenticate_request(request)
+ self.assertIsNotNone(result)
+ self.assertEqual(result.username, "devuser")
+ self.assertEqual(result.email, "dev@example.com")
+ group_names = set(result.groups.values_list("name", flat=True))
+ self.assertIn("vince_admin", group_names)
+ self.assertIn("analyst", group_names)
+
+ # ------------------------------------------------------------------
+ # authenticate_request – missing header returns None (not anonymous)
+ # ------------------------------------------------------------------
+ def test_missing_dev_header_returns_none(self):
+ request = self.factory.get("/")
+ request.user = AnonymousUser()
+ result = self.adapter.authenticate_request(request)
+ self.assertIsNone(result)
+
+ # ------------------------------------------------------------------
+ # authenticate_request – headers ignored when DEBUG=False
+ # ------------------------------------------------------------------
+ @override_settings(DEBUG=False)
+ def test_dev_headers_ignored_outside_debug(self):
+ request = self.factory.get(
+ "/",
+ **{"HTTP_X_DEV_USER": "should_not_be_created"},
+ )
+ request.user = AnonymousUser()
+ result = self.adapter.authenticate_request(request)
+ self.assertIsNone(result)
+ self.assertFalse(User.objects.filter(username="should_not_be_created").exists())
+
+ # ------------------------------------------------------------------
+ # sync_user – idempotent: calling twice must not duplicate groups
+ # ------------------------------------------------------------------
+ def test_sync_user_idempotent(self):
+ identity = Identity(
+ username="idempotent_user",
+ email="u@example.com",
+ groups=["g1", "g2"],
+ )
+ self.adapter.sync_user(identity)
+ self.adapter.sync_user(identity)
+ user = User.objects.get(username="idempotent_user")
+ self.assertEqual(user.groups.count(), 2)
+
+ # ------------------------------------------------------------------
+ # get_roles – returns correct group names
+ # ------------------------------------------------------------------
+ def test_get_roles(self):
+ identity = Identity(
+ username="roleuser",
+ email="r@example.com",
+ groups=["alpha", "beta"],
+ )
+ user = self.adapter.sync_user(identity)
+ roles = self.adapter.get_roles(user)
+ self.assertEqual(roles, {"alpha", "beta"})
+
+
+@override_settings(DEBUG=True, AUTH_BACKEND_MODE="local")
+class LocalAuthFactoryTest(TestCase):
+ """Tests for get_auth_adapter factory."""
+
+ def test_factory_returns_local_adapter(self):
+ adapter = get_auth_adapter()
+ self.assertIsInstance(adapter, LocalAuthAdapter)
+
+ @override_settings(AUTH_BACKEND_MODE="cognito")
+ def test_factory_returns_cognito_adapter(self):
+ from vince.auth.adapters import CognitoAuthAdapter
+ adapter = get_auth_adapter()
+ self.assertIsInstance(adapter, CognitoAuthAdapter)
+
+ @override_settings(AUTH_BACKEND_MODE="invalid_value")
+ def test_factory_raises_on_invalid_mode(self):
+ from django.core.exceptions import ImproperlyConfigured
+ with self.assertRaises(ImproperlyConfigured):
+ get_auth_adapter()
+
+
+@override_settings(DEBUG=True, AUTH_BACKEND_MODE="local")
+class WhoamiViewTest(TestCase):
+ """Smoke tests for the whoami debug endpoint."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ # ------------------------------------------------------------------
+ # Returns 200 with user info when dev headers are supplied.
+ # RequestFactory produces no REMOTE_ADDR → get_ip returns "Unknown"
+ # which the view treats as localhost (test-runner context).
+ # ------------------------------------------------------------------
+ def test_whoami_with_dev_headers(self):
+ request = self.factory.get(
+ "/vince/auth/whoami/",
+ **{
+ "HTTP_X_DEV_USER": "localtester",
+ "HTTP_X_DEV_EMAIL": "localtester@example.com",
+ "HTTP_X_DEV_GROUPS": "vince_admin, analyst",
+ },
+ )
+ request.user = AnonymousUser()
+ response = whoami(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content)
+ self.assertEqual(data["username"], "localtester")
+ self.assertEqual(data["email"], "localtester@example.com")
+ self.assertIn("vince_admin", data["groups"])
+ self.assertIn("analyst", data["groups"])
+
+ # ------------------------------------------------------------------
+ # Returns 200 when user is already authenticated via session.
+ # No REMOTE_ADDR → "Unknown" → allowed.
+ # ------------------------------------------------------------------
+ def test_whoami_with_authenticated_user(self):
+ user = User.objects.create_user(username="sessionuser")
+ request = self.factory.get("/vince/auth/whoami/")
+ request.user = user
+ response = whoami(request)
+ self.assertEqual(response.status_code, 200)
+ data = json.loads(response.content)
+ self.assertEqual(data["username"], "sessionuser")
+
+ # ------------------------------------------------------------------
+ # Returns 200 when REMOTE_ADDR is 127.0.0.1 explicitly.
+ # ------------------------------------------------------------------
+ def test_whoami_with_loopback_remote_addr(self):
+ user = User.objects.create_user(username="loopbackuser")
+ request = self.factory.get("/vince/auth/whoami/", REMOTE_ADDR="127.0.0.1")
+ request.user = user
+ response = whoami(request)
+ self.assertEqual(response.status_code, 200)
+
+ # ------------------------------------------------------------------
+ # Returns 200 when REMOTE_ADDR is IPv6 loopback ::1.
+ # ------------------------------------------------------------------
+ def test_whoami_with_ipv6_loopback_remote_addr(self):
+ user = User.objects.create_user(username="ipv6user")
+ request = self.factory.get("/vince/auth/whoami/", REMOTE_ADDR="::1")
+ request.user = user
+ response = whoami(request)
+ self.assertEqual(response.status_code, 200)
+
+ # ------------------------------------------------------------------
+ # Returns 200 when the user is already authenticated, even from a
+ # non-loopback address.
+ # ------------------------------------------------------------------
+ def test_whoami_allows_authenticated_non_localhost(self):
+ user = User.objects.create_user(username="remoteuser")
+ request = self.factory.get("/vince/auth/whoami/", REMOTE_ADDR="10.0.0.1")
+ request.user = user
+ response = whoami(request)
+ self.assertEqual(response.status_code, 200)
+
+ # ------------------------------------------------------------------
+ # Returns 200 when the user is already authenticated, even if
+ # X-Forwarded-For is non-loopback.
+ # ------------------------------------------------------------------
+ def test_whoami_allows_authenticated_forwarded_for_remote(self):
+ user = User.objects.create_user(username="proxieduser")
+ request = self.factory.get(
+ "/vince/auth/whoami/",
+ REMOTE_ADDR="127.0.0.1",
+ HTTP_X_FORWARDED_FOR="203.0.113.5",
+ )
+ request.user = user
+ response = whoami(request)
+ self.assertEqual(response.status_code, 200)
+
+ # ------------------------------------------------------------------
+ # Returns 401 when no auth is present in local+DEBUG mode.
+ # ------------------------------------------------------------------
+ def test_whoami_unauthenticated_returns_401(self):
+ request = self.factory.get("/vince/auth/whoami/")
+ request.user = AnonymousUser()
+ response = whoami(request)
+ self.assertEqual(response.status_code, 401)
+
+ # ------------------------------------------------------------------
+ # Returns 403 when DEBUG=False for unauthenticated requests because the
+ # dev bootstrap path must be unavailable in prod.
+ # ------------------------------------------------------------------
+ @override_settings(DEBUG=False)
+ def test_whoami_blocked_outside_debug(self):
+ request = self.factory.get("/vince/auth/whoami/")
+ request.user = AnonymousUser()
+ response = whoami(request)
+ self.assertEqual(response.status_code, 403)
+
+ # ------------------------------------------------------------------
+ # Returns 200 for an already-authenticated user even when DEBUG=False.
+ # ------------------------------------------------------------------
+ @override_settings(DEBUG=False)
+ def test_whoami_allows_authenticated_user_outside_debug(self):
+ user = User.objects.create_user(username="produser")
+ request = self.factory.get("/vince/auth/whoami/", REMOTE_ADDR="203.0.113.10")
+ request.user = user
+ response = whoami(request)
+ self.assertEqual(response.status_code, 200)
diff --git a/vinny/tests.py b/vince/auth/urls.py
similarity index 56%
rename from vinny/tests.py
rename to vince/auth/urls.py
index ed4465c..f10bdb6 100644
--- a/vinny/tests.py
+++ b/vince/auth/urls.py
@@ -14,18 +14,22 @@
# Released under a MIT (SEI)-style license, please see license.txt or contact
# permission@sei.cmu.edu for full terms.
#
-# [DISTRIBUTION STATEMENT A] This material has been approved for public
-# release and unlimited distribution. Please see Copyright notice for non-US
-# Government use and distribution.
-#
-# Carnegie Mellon®, CERT® and CERT Coordination Center® are registered in the
-# U.S. Patent and Trademark Office by Carnegie Mellon University.
-#
-# This Software includes and/or makes use of Third-Party Software each subject
-# to its own license.
-#
# DM21-1126
########################################################################
-from django.test import TestCase
+from django.urls import path
+
+from .views import whoami # uncomment to enable the whoami debug endpoint
-# Create your tests here.
+urlpatterns = [
+ # The whoami endpoint is disabled by default. It is a development/testing
+ # helper that returns JSON describing the authenticated user.
+ #
+ # To enable it locally:
+ # 1. Uncomment the import above.
+ # 2. Uncomment the path() entry below.
+ # 3. Ensure DEBUG=True and AUTH_BACKEND_MODE=local in your environment.
+ # The view enforces DEBUG=True itself (returns HTTP 403 otherwise), but
+ # keeping it wired up in production is an unnecessary attack surface.
+ #
+ path("whoami/", whoami, name="whoami"),
+]
diff --git a/vince/auth/views.py b/vince/auth/views.py
new file mode 100644
index 0000000..4a38855
--- /dev/null
+++ b/vince/auth/views.py
@@ -0,0 +1,92 @@
+#########################################################################
+# VINCE
+#
+# Copyright 2023 Carnegie Mellon University.
+#
+# NO WARRANTY. THIS CARNEGIE MELLON UNIVERSITY AND SOFTWARE ENGINEERING
+# INSTITUTE MATERIAL IS FURNISHED ON AN "AS-IS" BASIS. CARNEGIE MELLON
+# UNIVERSITY MAKES NO WARRANTIES OF ANY KIND, EITHER EXPRESSED OR IMPLIED,
+# AS TO ANY MATTER INCLUDING, BUT NOT LIMITED TO, WARRANTY OF FITNESS FOR
+# PURPOSE OR MERCHANTABILITY, EXCLUSIVITY, OR RESULTS OBTAINED FROM USE OF THE
+# MATERIAL. CARNEGIE MELLON UNIVERSITY DOES NOT MAKE ANY WARRANTY OF ANY KIND
+# WITH RESPECT TO FREEDOM FROM PATENT, TRADEMARK, OR COPYRIGHT INFRINGEMENT.
+#
+# Released under a MIT (SEI)-style license, please see license.txt or contact
+# permission@sei.cmu.edu for full terms.
+#
+# DM21-1126
+########################################################################
+"""
+Authenticated-user introspection endpoint.
+
+If the request already has an authenticated user from the configured auth
+backend, the view returns user details without applying the local dev-only
+bootstrap restrictions.
+
+In ``AUTH_BACKEND_MODE=local``, unauthenticated requests may still use the
+optional dev bootstrap headers ``X-Dev-User``, ``X-Dev-Email``, and
+``X-Dev-Groups`` to create/sync a local user on-the-fly. That fallback path
+remains restricted to ``DEBUG=True`` and localhost/test-runner requests.
+"""
+
+from django.conf import settings
+from django.http import JsonResponse
+
+from lib.vince import utils as vinceutils
+from vince.auth.service import authenticate_request
+
+# IPs that are unconditionally treated as localhost.
+_LOOPBACK_IPS = {"127.0.0.1", "::1"}
+
+
+def _is_localhost(request):
+ """Return True when the resolved client IP is a loopback address.
+
+ ``get_ip()`` returns ``"Unknown"`` when neither ``X-Forwarded-For`` nor
+ ``REMOTE_ADDR`` is present (e.g. Django's ``RequestFactory`` in tests).
+ That case is also allowed so that unit tests that don't set network
+ metadata can still exercise the view.
+ """
+ ip = vinceutils.get_ip(request)
+ # Strip port suffix if present (e.g. "127.0.0.1:52000" from some test runners).
+ ip = ip.split(":")[0] if ":" in ip and not ip.startswith("::") else ip
+ return ip in _LOOPBACK_IPS or ip == "Unknown"
+
+
+def whoami(request):
+ """
+ Return JSON describing the currently authenticated user.
+
+ Already-authenticated users are allowed through regardless of DEBUG or
+ client IP so normal backend-based authentication continues to work.
+
+ Unauthenticated requests may fall back to local dev bootstrap, but only
+ when:
+ * ``settings.DEBUG`` is ``True``.
+ * The client IP (resolved via ``lib.vince.utils.get_ip``) is a loopback
+ address (``127.0.0.1`` or ``::1``), or unresolvable
+ (``"Unknown"`` — test-runner / RequestFactory context).
+ """
+ user = request.user
+ if not getattr(user, "is_authenticated", False):
+ if not getattr(settings, "DEBUG", False):
+ return JsonResponse({"error": "Not available outside DEBUG mode."}, status=403)
+
+ if not _is_localhost(request):
+ return JsonResponse({"error": "Only accessible from localhost."}, status=403)
+
+ # In local mode, attempt header-based dev bootstrap if not already authed.
+ bootstrapped = authenticate_request(request)
+ if bootstrapped is not None:
+ user = bootstrapped
+
+ if not getattr(user, "is_authenticated", False):
+ return JsonResponse({"error": "Authentication required."}, status=401)
+
+ return JsonResponse(
+ {
+ "username": user.username,
+ "email": user.email,
+ "groups": sorted(user.groups.values_list("name", flat=True)),
+ }
+ )
diff --git a/vince/fixtures/TicketQueue_d.json b/vince/fixtures/TicketQueue_d.json
new file mode 100644
index 0000000..2238257
--- /dev/null
+++ b/vince/fixtures/TicketQueue_d.json
@@ -0,0 +1,15 @@
+[
+ {
+ "model": "vince.ticketqueue",
+ "pk": 1,
+ "fields": {
+ "title": "General",
+ "slug": "general",
+ "new_ticket_cc": "newticket-cc@example.org",
+ "updated_ticket_cc": "updated-cc@example.org",
+ "default_owner": 1,
+ "queue_type": 1,
+ "from_email": null
+ }
+ }
+]
diff --git a/vince/fixtures/auth.json b/vince/fixtures/auth.json
new file mode 100644
index 0000000..acd58f1
--- /dev/null
+++ b/vince/fixtures/auth.json
@@ -0,0 +1,46 @@
+[
+ {
+ "model": "auth.group",
+ "pk": 1,
+ "fields": {
+ "name": "vince",
+ "permissions": []
+ }
+ },
+ {
+ "model": "auth.user",
+ "pk": 1,
+ "fields": {
+ "password": "!unusable_password_hash_not_for_login",
+ "last_login": null,
+ "is_superuser": false,
+ "username": "vinceuser",
+ "first_name": "Vince",
+ "last_name": "User",
+ "email": "vinceuser@example.org",
+ "is_staff": true,
+ "is_active": true,
+ "date_joined": "2022-01-01T00:00:00Z",
+ "groups": [1],
+ "user_permissions": []
+ }
+ },
+ {
+ "model": "auth.user",
+ "pk": 2,
+ "fields": {
+ "password": "!unusable_password_hash_not_for_login",
+ "last_login": null,
+ "is_superuser": false,
+ "username": "test1",
+ "first_name": "Test",
+ "last_name": "User",
+ "email": "test1@example.org",
+ "is_staff": true,
+ "is_active": true,
+ "date_joined": "2022-01-01T00:00:00Z",
+ "groups": [1],
+ "user_permissions": []
+ }
+ }
+]
diff --git a/vince/forms.py b/vince/forms.py
index f5749f2..7b73879 100644
--- a/vince/forms.py
+++ b/vince/forms.py
@@ -3143,7 +3143,7 @@ def clean_ticket(self):
queues = list(TicketQueue.objects.all().values_list("slug", flat=True))
queues.append("General")
rq = "|".join(queues)
- rq = "(?i)(" + rq + ")-(\d+)"
+ rq = "(?i)(" + rq + r")-(\d+)"
m = re.search(rq, data)
if m:
data = m.group(2)
diff --git a/vince/lib.py b/vince/lib.py
index 83ec2e6..cbc7d4c 100644
--- a/vince/lib.py
+++ b/vince/lib.py
@@ -2109,7 +2109,7 @@ def create_bounce_ticket(headers, bounce_info):
queue = TicketQueue.objects.filter(title="General").first()
nqueue = None
# do ticket search for
- rq = "(?i)(" + rq + ")-(\d+)"
+ rq = "(?i)(" + rq + r")-(\d+)"
m = re.search(rq, subject)
if m:
q = m.group(1)
@@ -2135,7 +2135,7 @@ def create_bounce_ticket(headers, bounce_info):
create_bounce_record(email, bounce_type, subject, ticket)
return
if not ticket:
- m = re.search(f"{settings.CASE_IDENTIFIER}(\d+)", subject, re.IGNORECASE)
+ m = re.search(fr"{settings.CASE_IDENTIFIER}(\d+)", subject, re.IGNORECASE)
if m:
# search for case for vu#
@@ -2622,7 +2622,7 @@ def create_ticket_from_email(filename, body, bucket):
nqueue = None
# do ticket search for
- rq = "(?i)(" + rq + ")-(\d+)"
+ rq = "(?i)(" + rq + r")-(\d+)"
m = re.search(rq, subject)
if m:
q = m.group(1)
@@ -2679,7 +2679,7 @@ def create_ticket_from_email(filename, body, bucket):
if not ticket:
# didn't find a ticket, so search cases
- case_regex = f"{settings.CASE_IDENTIFIER}(\d+)"
+ case_regex = fr"{settings.CASE_IDENTIFIER}(\d+)"
m = re.search(case_regex, subject, re.IGNORECASE)
if m:
# search for case for vu#
diff --git a/vince/management/commands/parse_action_items.py b/vince/management/commands/parse_action_items.py
new file mode 100644
index 0000000..c95e7c2
--- /dev/null
+++ b/vince/management/commands/parse_action_items.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+"""
+file: action_item_parser
+author: adh
+created_at: 4/20/20 4:08 PM
+"""
+import pandas as pd
+import re
+import os
+from django.core.management.base import BaseCommand, CommandError
+
+
+col_order = [
+ "AssignedDate",
+ "Task",
+ "Reference",
+ "NameOfReference",
+ "Resolution",
+ "Status",
+ "AssignedTo",
+ "Priority",
+ "ID",
+ "Category",
+ "Duty",
+ "DateCompleted",
+ "CompletedBy",
+ "mtime",
+ "CertMail",
+]
+
+date_cols = ["ctime", "mtime", "DueDate", "DateCompleted", "AssignedDate"]
+
+
+def main(actions, outdir):
+ os.makedirs(outdir, exist_ok=True)
+
+ # read data
+ df = pd.read_csv(
+ actions, delimiter="~", error_bad_lines=False, encoding="iso-8859-1"
+ )
+
+ # clean data
+ for col in date_cols:
+ df[col] = pd.to_datetime(df[col])
+ # we only want to keep a subset of columns
+ df = df[col_order]
+ # sort the data by assigned date, newest at the top
+ df = df.sort_values(by="AssignedDate", ascending=False)
+
+ # "Reference" holds the case ID
+ for name, group in df.groupby("Reference"):
+ # We want to create one file per case
+ if not(name.startswith("VU")):
+ continue
+ name = name.strip()
+ fname_base = re.sub(r"\W", "_", name)
+ fname = f"{fname_base}.txt"
+ fpath = os.path.join(outdir, fname)
+
+ # get the data for this case as a list of dicts
+ gdict = group.to_dict(orient="records")
+
+ # write out key: value pairs for each record with a blank line between them
+ with open(fpath, "w") as fp:
+ for d in gdict:
+ for k in col_order:
+ fp.write(f"{k}: {d[k]}\n")
+ fp.write("\n")
+
+
+class Command(BaseCommand):
+ help = "Translate LN Action Items CSV into text files by case ID"
+
+ def add_arguments(self, parser):
+
+ parser.add_argument('--csv', dest="csvfile",
+ action="store",
+ type=str,
+ default="actions.csv",
+ help="path to csv file input",
+ )
+ parser.add_argument('--outdir', dest="outdir", default='./out',
+ type=str,
+ help='path to dir where data should be output')
+
+ def handle(self, *args, **options):
+ main(options["csvfile"], options["outdir"])
diff --git a/vince/templates/vince/group.html b/vince/templates/vince/group.html
index 2fe4e38..02353f4 100644
--- a/vince/templates/vince/group.html
+++ b/vince/templates/vince/group.html
@@ -108,7 +108,6 @@