geenforce: resource-scope binding for fetch tokens (0.15.0)
A geenforce.fetch token can now be pinned to specific manifest scopes so a fleet-wide key (a display's, delivered by DSC or baked into the image) is not a skeleton key for the whole content store. NULL binding = unrestricted, so every existing service token keeps working. Core: - ApiToken.resourcescopes column + resourcescopelist property (migration 7d30_apitoken_resourcescopes; NULL = unrestricted). - apitokens API create/update accept + persist an optional resourcescopes list (a resource-name allowlist; not permission-catalog names). - New contract helper authorized_service_token(scope): same check as service_token_authorized but returns the ApiToken so a plugin can read its binding. Contract 0.14.0 -> 0.15.0; also export SupportTeam. GE-Enforce enforcement: - get_manifest: a bound token requesting a scope outside its allowlist -> 403. - get_payload: a bound token may only pull a blob its own scope(s) reference (service.blob_referenced_by_scopes); anything else -> 404 (no hash probing). - Decorator stashes the authorized token on g for the route to read. Also fixes a pre-existing contract-surface violation: the printers/printedparts alert helpers imported shopdb.core.models / shopdb.extensions directly; now via shopdb.api (SupportTeam newly exported). Docs: GE-ENFORCE-DISPLAY.md provisioning note, PLUGIN-HOOKS.md, CLAUDE.md. 9 new resource-binding tests; full suite 1131 passing.
This commit is contained in:
@@ -45,7 +45,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely
|
|||||||
### Active state
|
### Active state
|
||||||
|
|
||||||
- 1077 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a lean-build job + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
|
- 1077 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a lean-build job + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8)
|
||||||
- `__contract_version__` at 0.14.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
- `__contract_version__` at 0.15.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook, 0.15.0 authorized_service_token) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007)
|
||||||
- 13 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty
|
- 13 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty
|
||||||
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d26_settings_description_text` (33 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
|
- Core Alembic chain: baseline `68b3947ae14f` -> head `7d26_settings_description_text` (33 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8).
|
||||||
- Lean per-site builds (ADR-013 + ADR-014): `scripts/build-site.sh` (backend) + `SITE_PLUGINS` via `scripts/stage-frontend.mjs` (frontend) ship only chosen plugins; `flask plugin prune-schema` drops non-installed plugins' tables at provisioning. Sidebar nav / settings / Displays all gate on staged routes. Manifest-less `plugins/<name>/frontend/` dirs (e.g. `applications`) are core and always ship.
|
- Lean per-site builds (ADR-013 + ADR-014): `scripts/build-site.sh` (backend) + `SITE_PLUGINS` via `scripts/stage-frontend.mjs` (frontend) ship only chosen plugins; `flask plugin prune-schema` drops non-installed plugins' tables at provisioning. Sidebar nav / settings / Displays all gate on staged routes. Manifest-less `plugins/<name>/frontend/` dirs (e.g. `applications`) are core and always ship.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# GE-Enforce: the gea-shopfloor-display scope
|
# GE-Enforce: the gea-shopfloor-display scope
|
||||||
|
|
||||||
Displays are the share-less corner of the fleet. They are Intune/Entra-joined,
|
Displays are the share-less corner of the fleet. They are Entra-joined,
|
||||||
credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and
|
credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and
|
||||||
authenticate with a read-only service PAT scoped `geenforce.fetch`, sent as
|
authenticate with a read-only service PAT scoped `geenforce.fetch`, sent as
|
||||||
`X-API-Key`. They have no SMB share mount. The kiosk engine and the kiosk
|
`X-API-Key`. They have no SMB share mount. The kiosk engine and the kiosk
|
||||||
@@ -8,6 +8,25 @@ browser are baked into the display image, not shipped over HTTPS, so the display
|
|||||||
manifest heals POLICY / CONFIG drift only, never EXEs. It is self-sufficient and
|
manifest heals POLICY / CONFIG drift only, never EXEs. It is self-sufficient and
|
||||||
does not inherit the fleet-wide `common` scope (see below).
|
does not inherit the fleet-wide `common` scope (see below).
|
||||||
|
|
||||||
|
## The display fetch token MUST be resource-bound
|
||||||
|
|
||||||
|
The same read-only key ships to every display (delivered by DSC, or baked into
|
||||||
|
the image), so it must not be a skeleton key for the whole content store. Mint
|
||||||
|
the display token bound to just this scope, so a leak cannot pull any other
|
||||||
|
scope's manifest or any blob by hash:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/apitokens
|
||||||
|
{ "name": "display fetch", "scopes": ["geenforce.fetch"],
|
||||||
|
"resourcescopes": ["gea-shopfloor-display"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
With `resourcescopes` set, `GET /manifest?pctype=<other>` returns 403 and
|
||||||
|
`GET /payload/<sha>` returns 404 for any blob the display scope does not
|
||||||
|
reference. `resourcescopes` NULL (unset) = unrestricted, for back-compat with
|
||||||
|
existing service tokens. Rotate by minting a new bound token and revoking the
|
||||||
|
old one (deactivate it server-side); DSC re-delivers, or re-image.
|
||||||
|
|
||||||
There are three display subtypes, selected by `C:\Enrollment\display-type.txt`:
|
There are three display subtypes, selected by `C:\Enrollment\display-type.txt`:
|
||||||
`Dashboard`, `Lobby`, and `3DPrintRoom`.
|
`Dashboard`, `Lobby`, and `3DPrintRoom`.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
|||||||
The framework declares its contract version in `shopdb/__init__.py`:
|
The framework declares its contract version in `shopdb/__init__.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
__contract_version__ = '0.14.0'
|
__contract_version__ = '0.15.0'
|
||||||
```
|
```
|
||||||
|
|
||||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||||
@@ -497,6 +497,12 @@ What `shopdb.api` exposes:
|
|||||||
(`service_token_authorized(scope)` returns True when the request carries a
|
(`service_token_authorized(scope)` returns True when the request carries a
|
||||||
managed service token scoped for `scope` whose owner holds that permission -
|
managed service token scoped for `scope` whose owner holds that permission -
|
||||||
for unattended plugin endpoints like the GE-Enforce fetch API)
|
for unattended plugin endpoints like the GE-Enforce fetch API)
|
||||||
|
- `authorized_service_token(scope)` (0.15.0) - same check as
|
||||||
|
`service_token_authorized` but returns the `ApiToken` itself (or None), so a
|
||||||
|
plugin can honor the token's optional resource binding
|
||||||
|
(`token.resourcescopelist`: an allowlist of resource names the token may
|
||||||
|
reach, NULL = unrestricted). GE-Enforce uses it to pin a display's fetch
|
||||||
|
token to its own manifest scope + that scope's blobs.
|
||||||
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
||||||
`dualpath_single_machine_enabled`
|
`dualpath_single_machine_enabled`
|
||||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||||
@@ -506,6 +512,8 @@ What `shopdb.api` exposes:
|
|||||||
`cmmc_usb_connection`
|
`cmmc_usb_connection`
|
||||||
- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
|
- `User` / `Role` (0.13.0) - the account and role models, e.g. resolving
|
||||||
alert recipients' emails from selected user ids or role membership
|
alert recipients' emails from selected user ids or role membership
|
||||||
|
- `SupportTeam` (0.15.0) - the support-team model (carries a `webhookurl`), so
|
||||||
|
an alerting plugin can route a notification to a chosen team's Teams webhook
|
||||||
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
|
- Mailer (0.12.0): `send_email(to, subject, html, text=None)` and
|
||||||
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
|
`send_alert(subject, html, text=None)` - settings-first, no-op safe when
|
||||||
email is unconfigured; send_alert targets the site's alert_recipients
|
email is unconfigured; send_alert targets the site's alert_recipients
|
||||||
|
|||||||
36
migrations/versions/7d30_apitoken_resourcescopes.py
Normal file
36
migrations/versions/7d30_apitoken_resourcescopes.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""apitokens.resourcescopes: pin a service token to specific resource scopes.
|
||||||
|
|
||||||
|
A geenforce.fetch token handed to a fleet (e.g. displays) should reach only its
|
||||||
|
own manifest scope(s) and the blobs those scopes ship, not every scope by name
|
||||||
|
or every blob by hash. This nullable JSON column carries that allowlist; NULL =
|
||||||
|
unrestricted, so every existing token keeps working unchanged. Idempotent.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7d30_apitoken_resourcescopes'
|
||||||
|
down_revision = '7d29_supportteam_webhookurl'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(bind, table, column):
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if table not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
return column in {c['name'] for c in inspector.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if not _has_column(bind, 'apitokens', 'resourcescopes'):
|
||||||
|
op.add_column('apitokens',
|
||||||
|
sa.Column('resourcescopes', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _has_column(bind, 'apitokens', 'resourcescopes'):
|
||||||
|
op.drop_column('apitokens', 'resourcescopes')
|
||||||
@@ -12,13 +12,13 @@ import os
|
|||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, Response, send_file, current_app
|
from flask import Blueprint, request, Response, send_file, current_app, g
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||||
service_token_authorized, Setting, Application,
|
authorized_service_token, Setting, Application,
|
||||||
)
|
)
|
||||||
|
|
||||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||||
@@ -51,7 +51,12 @@ def _require_service_token(scope):
|
|||||||
def wrapper(f):
|
def wrapper(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
if service_token_authorized(scope):
|
token = authorized_service_token(scope)
|
||||||
|
if token is not None:
|
||||||
|
# Stash for the route so it can honor the token's resource
|
||||||
|
# binding (token.resourcescopelist restricts which manifest
|
||||||
|
# scopes + blobs this token may pull; None = unrestricted).
|
||||||
|
g.geenforce_token = token
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||||
http_code=401)
|
http_code=401)
|
||||||
@@ -59,6 +64,14 @@ def _require_service_token(scope):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _token_resource_scopes():
|
||||||
|
"""Resource-scope allowlist for the authorized token, or None if the token
|
||||||
|
may reach any scope. A bound token (e.g. a display's) is pinned to its own
|
||||||
|
manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
|
||||||
|
token = getattr(g, 'geenforce_token', None)
|
||||||
|
return token.resourcescopelist if token is not None else None
|
||||||
|
|
||||||
|
|
||||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||||
|
|
||||||
@@ -80,6 +93,11 @@ def get_manifest():
|
|||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
'pctype is required', http_code=400)
|
'pctype is required', http_code=400)
|
||||||
|
|
||||||
|
allowed = _token_resource_scopes()
|
||||||
|
if allowed is not None and scopename not in allowed:
|
||||||
|
return error_response(ErrorCodes.FORBIDDEN,
|
||||||
|
'token is not allowed this scope', http_code=403)
|
||||||
|
|
||||||
scope = ManifestScope.query.filter_by(
|
scope = ManifestScope.query.filter_by(
|
||||||
scopename=scopename, phase=phase).first()
|
scopename=scopename, phase=phase).first()
|
||||||
if not scope:
|
if not scope:
|
||||||
@@ -178,6 +196,15 @@ def get_payload(sha256):
|
|||||||
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
||||||
http_code=400)
|
http_code=400)
|
||||||
|
|
||||||
|
# A resource-bound token may only pull a blob its own scope(s) reference.
|
||||||
|
# Return 404 (not 403) so it cannot probe which hashes exist. Checked before
|
||||||
|
# the 304 shortcut so a bound token cannot even confirm a hash via ETag.
|
||||||
|
allowed = _token_resource_scopes()
|
||||||
|
if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
etag = f'"{sha}"'
|
etag = f'"{sha}"'
|
||||||
if request.headers.get('If-None-Match') == etag:
|
if request.headers.get('If-None-Match') == etag:
|
||||||
return Response(status=304, headers={'ETag': etag})
|
return Response(status=304, headers={'ETag': etag})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -288,6 +289,34 @@ def blob_path(sha256):
|
|||||||
return os.path.join(_payload_dir(), sha256)
|
return os.path.join(_payload_dir(), sha256)
|
||||||
|
|
||||||
|
|
||||||
|
def blob_referenced_by_scopes(sha256, scopenames):
|
||||||
|
"""True when any of these scopes' CURRENT published manifest references
|
||||||
|
sha256 as an entry payload.
|
||||||
|
|
||||||
|
Backs the resource-bound token check on GET /payload: a token pinned to its
|
||||||
|
own scope(s) may only pull blobs those scopes actually ship, not any blob by
|
||||||
|
hash. Empty scopenames -> False (a bound-but-empty token reaches nothing).
|
||||||
|
"""
|
||||||
|
if not scopenames:
|
||||||
|
return False
|
||||||
|
rows = db.session.query(ManifestPublishedVersion).join(
|
||||||
|
ManifestScope,
|
||||||
|
ManifestPublishedVersion.scopeid == ManifestScope.scopeid,
|
||||||
|
).filter(
|
||||||
|
ManifestScope.scopename.in_(list(scopenames)),
|
||||||
|
ManifestPublishedVersion.iscurrent == True, # noqa: E712
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
doc = json.loads(row.manifestjson)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
for entry in doc.get('Applications', []) or []:
|
||||||
|
if entry.get('PayloadSha256') == sha256:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def store_blob(rawbytes, filename, contenttype=None):
|
def store_blob(rawbytes, filename, contenttype=None):
|
||||||
"""Store bytes in the content-addressed payload store; return the sha256.
|
"""Store bytes in the content-addressed payload store; return the sha256.
|
||||||
|
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ def _alert_team_webhook():
|
|||||||
if not team_id:
|
if not team_id:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
from shopdb.core.models import SupportTeam
|
from shopdb.api import SupportTeam
|
||||||
team = db.session.get(SupportTeam, int(team_id))
|
team = db.session.get(SupportTeam, int(team_id))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ printer + supply. One row per (printerid, supplykey); supplykey is the toner
|
|||||||
color (black/cyan/magenta/yellow) or the raw item name when color is unknown.
|
color (black/cyan/magenta/yellow) or the raw item name when color is unknown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.api import db, BaseModel
|
||||||
from shopdb.core.models.base import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class PrinterSupplyAlert(BaseModel):
|
class PrinterSupplyAlert(BaseModel):
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def alert_team_webhook():
|
|||||||
if not team_id:
|
if not team_id:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
from shopdb.core.models import SupportTeam
|
from shopdb.api import SupportTeam
|
||||||
team = db.session.get(SupportTeam, int(team_id))
|
team = db.session.get(SupportTeam, int(team_id))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from .plugins import plugin_manager
|
|||||||
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
||||||
# managed service token without importing core token internals. Additive name
|
# managed service token without importing core token internals. Additive name
|
||||||
# on the import surface, minor bump.
|
# on the import surface, minor bump.
|
||||||
__contract_version__ = '0.14.0'
|
__contract_version__ = '0.15.0'
|
||||||
|
|
||||||
# Product release version (see ADR-007). The product version and the
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from shopdb.core.models import (
|
|||||||
RelationshipType,
|
RelationshipType,
|
||||||
User,
|
User,
|
||||||
Role,
|
Role,
|
||||||
|
SupportTeam,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Response + pagination helpers for plugin API blueprints
|
# Response + pagination helpers for plugin API blueprints
|
||||||
@@ -63,7 +64,9 @@ from shopdb.utils.authz import require_permission, require_role
|
|||||||
# Service-token authorization for unattended plugin endpoints (collector,
|
# Service-token authorization for unattended plugin endpoints (collector,
|
||||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||||
# internals.
|
# internals.
|
||||||
from shopdb.utils.apitoken_auth import service_token_authorized
|
from shopdb.utils.apitoken_auth import (
|
||||||
|
service_token_authorized, authorized_service_token,
|
||||||
|
)
|
||||||
|
|
||||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||||
from shopdb.utils.import_mode import (
|
from shopdb.utils.import_mode import (
|
||||||
@@ -263,6 +266,8 @@ __all__ = [
|
|||||||
'require_permission',
|
'require_permission',
|
||||||
'require_role',
|
'require_role',
|
||||||
'service_token_authorized',
|
'service_token_authorized',
|
||||||
|
'authorized_service_token',
|
||||||
|
'SupportTeam',
|
||||||
# Import-mode helpers
|
# Import-mode helpers
|
||||||
'apply_import_timestamps',
|
'apply_import_timestamps',
|
||||||
'import_mode_active',
|
'import_mode_active',
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ def _validate_scopes(scopes, owner):
|
|||||||
return scopes, None
|
return scopes, None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_resourcescopes(resourcescopes):
|
||||||
|
"""Validate a resourcescopes payload. Return (list_or_none, error_response).
|
||||||
|
|
||||||
|
resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set
|
||||||
|
of resource names (today geenforce manifest scope names, e.g.
|
||||||
|
gea-shopfloor-display), not permission names, so there is no catalog to
|
||||||
|
check against - a plugin owns the meaning. None/absent = unrestricted.
|
||||||
|
"""
|
||||||
|
if resourcescopes is None:
|
||||||
|
return None, None
|
||||||
|
if not isinstance(resourcescopes, list) \
|
||||||
|
or not all(isinstance(s, str) for s in resourcescopes):
|
||||||
|
return None, error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'resourcescopes must be a list of resource-scope names')
|
||||||
|
return [s.strip() for s in resourcescopes if s.strip()], None
|
||||||
|
|
||||||
|
|
||||||
@apitokens_bp.route('', methods=['GET'])
|
@apitokens_bp.route('', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def list_apitokens():
|
def list_apitokens():
|
||||||
@@ -93,6 +111,11 @@ def create_apitoken():
|
|||||||
if scope_error is not None:
|
if scope_error is not None:
|
||||||
return scope_error
|
return scope_error
|
||||||
|
|
||||||
|
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||||
|
data.get('resourcescopes'))
|
||||||
|
if resource_error is not None:
|
||||||
|
return resource_error
|
||||||
|
|
||||||
secret = ApiToken.generate_secret()
|
secret = ApiToken.generate_secret()
|
||||||
token = ApiToken(
|
token = ApiToken(
|
||||||
userid=current_user.userid,
|
userid=current_user.userid,
|
||||||
@@ -102,6 +125,7 @@ def create_apitoken():
|
|||||||
expiresat=expiresat,
|
expiresat=expiresat,
|
||||||
)
|
)
|
||||||
token.scopelist = scopelist
|
token.scopelist = scopelist
|
||||||
|
token.resourcescopelist = resourcescopelist
|
||||||
db.session.add(token)
|
db.session.add(token)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
@@ -147,6 +171,12 @@ def update_apitoken(tokenid: int):
|
|||||||
if scope_error is not None:
|
if scope_error is not None:
|
||||||
return scope_error
|
return scope_error
|
||||||
token.scopelist = scopelist
|
token.scopelist = scopelist
|
||||||
|
if 'resourcescopes' in data:
|
||||||
|
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||||
|
data.get('resourcescopes'))
|
||||||
|
if resource_error is not None:
|
||||||
|
return resource_error
|
||||||
|
token.resourcescopelist = resourcescopelist
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(token.to_dict(), message='Token updated')
|
return success_response(token.to_dict(), message='Token updated')
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ class ApiToken(BaseModel):
|
|||||||
# authority). A scoped token grants ONLY these, intersected with what the
|
# authority). A scoped token grants ONLY these, intersected with what the
|
||||||
# owner holds, and suspends the admin bypass. See scopelist below.
|
# owner holds, and suspends the admin bypass. See scopelist below.
|
||||||
scopes = db.Column(db.Text, nullable=True)
|
scopes = db.Column(db.Text, nullable=True)
|
||||||
|
# JSON array of RESOURCE-scope names (a different axis from `scopes`, which
|
||||||
|
# is what the token may DO). Today these are geenforce manifest scope names
|
||||||
|
# (e.g. gea-shopfloor-display): a resource-bound geenforce.fetch token may
|
||||||
|
# only pull those scopes' manifests and only blobs those manifests
|
||||||
|
# reference. NULL = unrestricted (any resource), for back-compat. See
|
||||||
|
# resourcescopelist below.
|
||||||
|
resourcescopes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
|
user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic'))
|
||||||
|
|
||||||
@@ -98,6 +105,25 @@ class ApiToken(BaseModel):
|
|||||||
else:
|
else:
|
||||||
self.scopes = json.dumps(list(names))
|
self.scopes = json.dumps(list(names))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resourcescopelist(self):
|
||||||
|
"""Parsed resource-scope names, or None when the token is unrestricted."""
|
||||||
|
if self.resourcescopes is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(self.resourcescopes)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return value if isinstance(value, list) else None
|
||||||
|
|
||||||
|
@resourcescopelist.setter
|
||||||
|
def resourcescopelist(self, names):
|
||||||
|
"""Store a resource-scope list, or None to clear the restriction."""
|
||||||
|
if names is None:
|
||||||
|
self.resourcescopes = None
|
||||||
|
else:
|
||||||
|
self.resourcescopes = json.dumps(list(names))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def unknown_scope_names(names) -> list:
|
def unknown_scope_names(names) -> list:
|
||||||
"""Return the subset of names that are not in the permission catalog.
|
"""Return the subset of names that are not in the permission catalog.
|
||||||
@@ -119,6 +145,7 @@ class ApiToken(BaseModel):
|
|||||||
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
|
'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None,
|
||||||
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
|
'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None,
|
||||||
'scopes': self.scopelist,
|
'scopes': self.scopelist,
|
||||||
|
'resourcescopes': self.resourcescopelist,
|
||||||
'isactive': self.isactive,
|
'isactive': self.isactive,
|
||||||
'isexpired': self.is_expired,
|
'isexpired': self.is_expired,
|
||||||
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||||
|
|||||||
@@ -76,16 +76,13 @@ def touch_apitoken_lastused(token):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def service_token_authorized(scope):
|
def authorized_service_token(scope):
|
||||||
"""True when the current request carries a managed token scoped for `scope`
|
"""Return the ApiToken authorizing this request for `scope`, or None.
|
||||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
|
||||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
|
||||||
Touches lastusedat on success.
|
|
||||||
|
|
||||||
The single contract-surface entry point for unattended SERVICE tokens
|
Same checks as service_token_authorized (managed token scoped for `scope`,
|
||||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
active owner holding the permission), but hands back the token itself so a
|
||||||
token without reaching into core token internals. Returns False on any
|
caller can read its resource binding (token.resourcescopelist) without
|
||||||
miss; the caller returns its own 401.
|
reaching into core token internals. Touches lastusedat on success.
|
||||||
"""
|
"""
|
||||||
from shopdb.core.models import User
|
from shopdb.core.models import User
|
||||||
|
|
||||||
@@ -99,15 +96,29 @@ def service_token_authorized(scope):
|
|||||||
if tokenid is not None:
|
if tokenid is not None:
|
||||||
token = db.session.get(ApiToken, tokenid)
|
token = db.session.get(ApiToken, tokenid)
|
||||||
if token is None:
|
if token is None:
|
||||||
return False
|
return None
|
||||||
scopelist = token.scopelist
|
scopelist = token.scopelist
|
||||||
if not scopelist or scope not in scopelist:
|
if not scopelist or scope not in scopelist:
|
||||||
return False
|
return None
|
||||||
user = db.session.get(User, token.userid)
|
user = db.session.get(User, token.userid)
|
||||||
if user is None or not user.isactive or not user.haspermission(scope):
|
if user is None or not user.isactive or not user.haspermission(scope):
|
||||||
return False
|
return None
|
||||||
touch_apitoken_lastused(token)
|
touch_apitoken_lastused(token)
|
||||||
return True
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def service_token_authorized(scope):
|
||||||
|
"""True when the current request carries a managed token scoped for `scope`
|
||||||
|
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||||
|
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||||
|
Touches lastusedat on success.
|
||||||
|
|
||||||
|
The single contract-surface entry point for unattended SERVICE tokens
|
||||||
|
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||||
|
token without reaching into core token internals. Returns False on any
|
||||||
|
miss; the caller returns its own 401.
|
||||||
|
"""
|
||||||
|
return authorized_service_token(scope) is not None
|
||||||
|
|
||||||
|
|
||||||
def install_apitoken_auth(app):
|
def install_apitoken_auth(app):
|
||||||
|
|||||||
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal file
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Resource-scope binding on geenforce fetch tokens.
|
||||||
|
|
||||||
|
A geenforce.fetch token MAY be pinned to specific manifest scopes
|
||||||
|
(ApiToken.resourcescopes). A bound token can only pull those scopes' manifests
|
||||||
|
and only the blobs those scopes reference; an unbound token (NULL) keeps the
|
||||||
|
original behavior. This is what lets a fleet-wide display key be handed out
|
||||||
|
without exposing every scope + every blob in the content store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from plugins.geenforce import service
|
||||||
|
|
||||||
|
DISPLAY = 'gea-shopfloor-display'
|
||||||
|
OTHER = 'gea-shopfloor-cmm'
|
||||||
|
|
||||||
|
KIOSK_BYTES = b'kiosk-installer-payload-bytes'
|
||||||
|
KIOSK_SHA = hashlib.sha256(KIOSK_BYTES).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_display_scope(app):
|
||||||
|
"""Publish a display scope whose one entry references KIOSK_SHA over http."""
|
||||||
|
with app.app_context():
|
||||||
|
service.store_blob(KIOSK_BYTES, 'kiosk.exe', 'application/octet-stream')
|
||||||
|
manifest = {
|
||||||
|
'Version': '2.0',
|
||||||
|
'Applications': [
|
||||||
|
{'Name': 'Kiosk', 'Type': 'EXE', 'Installer': 'apps/kiosk.exe',
|
||||||
|
'DetectionMethod': 'Always',
|
||||||
|
'PayloadSource': 'http', 'PayloadSha256': KIOSK_SHA},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
service.replace_scope_draft(DISPLAY, 'runtime', manifest)
|
||||||
|
service.publish_scope(DISPLAY, 'runtime', notes='display')
|
||||||
|
# A second, unrelated scope + blob the display token must NOT reach.
|
||||||
|
service.store_blob(b'secret-other-scope-bytes', 'other.exe')
|
||||||
|
service.replace_scope_draft(OTHER, 'runtime', {
|
||||||
|
'Version': '2.6',
|
||||||
|
'Applications': [{'Name': 'Beta', 'Type': 'PS1',
|
||||||
|
'Script': 'scripts/beta.ps1',
|
||||||
|
'DetectionMethod': 'Always'}]})
|
||||||
|
service.publish_scope(OTHER, 'runtime', notes='other')
|
||||||
|
service.db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _mint(client, auth_headers, resourcescopes=None):
|
||||||
|
payload = {'name': 'display svc', 'scopes': ['geenforce.fetch']}
|
||||||
|
if resourcescopes is not None:
|
||||||
|
payload['resourcescopes'] = resourcescopes
|
||||||
|
resp = client.post('/api/apitokens', json=payload, headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
return resp.get_json()['data']
|
||||||
|
|
||||||
|
|
||||||
|
def _key(client, auth_headers, resourcescopes=None):
|
||||||
|
return {'X-API-Key': _mint(client, auth_headers, resourcescopes)['secret']}
|
||||||
|
|
||||||
|
|
||||||
|
# -- manifest binding ---------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bound_token_gets_its_own_scope(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_token_forbidden_other_scope(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/manifest?pctype={OTHER}', headers=headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_token_reaches_any_scope(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers) # no resourcescopes -> unrestricted
|
||||||
|
assert client.get(f'/api/geenforce/manifest?pctype={DISPLAY}',
|
||||||
|
headers=headers).status_code == 200
|
||||||
|
assert client.get(f'/api/geenforce/manifest?pctype={OTHER}',
|
||||||
|
headers=headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# -- payload binding ----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_bound_token_gets_referenced_blob(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/payload/{KIOSK_SHA}', headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == KIOSK_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_token_404_on_unreferenced_blob(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
# A blob the display scope does NOT reference (belongs to OTHER).
|
||||||
|
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
|
||||||
|
headers = _key(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
resp = client.get(f'/api/geenforce/payload/{other_sha}', headers=headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_token_gets_any_blob(client, app, db, auth_headers):
|
||||||
|
_publish_display_scope(app)
|
||||||
|
other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest()
|
||||||
|
headers = _key(client, auth_headers)
|
||||||
|
assert client.get(f'/api/geenforce/payload/{other_sha}',
|
||||||
|
headers=headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# -- token model / API --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_create_persists_resourcescopes(client, app, db, auth_headers):
|
||||||
|
data = _mint(client, auth_headers, resourcescopes=[DISPLAY])
|
||||||
|
assert data['resourcescopes'] == [DISPLAY]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resourcescopes_null_by_default(client, app, db, auth_headers):
|
||||||
|
data = _mint(client, auth_headers)
|
||||||
|
assert data['resourcescopes'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_resourcescopes_rejected(client, app, db, auth_headers):
|
||||||
|
resp = client.post('/api/apitokens',
|
||||||
|
json={'name': 'x', 'scopes': ['geenforce.fetch'],
|
||||||
|
'resourcescopes': 'not-a-list'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
Reference in New Issue
Block a user