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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ and the project adheres to CalVer (`YYYY.MM.PATCH`).
- Removed dead, unregistered `ForailOIDCAuth` backend to avoid implying a second
active OIDC backend. OIDC is handled by `social_core`'s `OpenIdConnectAuth`,
whose requests honor `SOCIAL_AUTH_OIDC_VERIFY_SSL` (verified).
- **Tenant isolation fails closed**: the RLS middleware now aborts a request
(HTTP 500) if it cannot install the tenant scope, instead of proceeding with
global row visibility. The strict-isolation gate resolves the target org with
the caller's RLS scope removed (so cross-tenant objects are actually visible)
and defaults to deny when a covered resource's org cannot be determined.
- **RLS coverage + robustness**: added a policy for `main_eventlog` (it carries
its own `organization_id`); RLS policies now cast the tenant GUC via
`NULLIF(current_setting(...), '')::int` so the empty "no scope" sentinel can't
raise. New migrations `0209`, `0210` (idempotent).
- **`import_from_awx` trust boundary**: superuser / system-role promotion from
the source is gated behind `--grant-superusers` (off by default, logged);
custom credential-type injectors are dropped for admin re-approval unless
`--trust-injectors` is given; secrets are read from `AWX_TOKEN` / `AWX_PASSWORD`
in preference to argv.
- **SSO account-takeover fix**: `associate_by_email` removed from the auth
pipeline — accounts associate by provider UID, not by matching email address.
- **Tenant provisioning** refuses to silently reuse an existing username (which
discarded the supplied password and cross-linked accounts) unless
`attach_existing_admin` is set.
- **IaC scanner path traversal**: a job template's `playbook` field can no
longer point the scanner outside the project checkout (absolute / `..`).
- **Rate limiter** logs Redis outages loudly and honours a new
`TENANCY_RATE_LIMIT_FAIL_CLOSED` setting (default open for availability).

### Fixed
- `pytest.ini` pointed `DJANGO_SETTINGS_MODULE` at the pre-rename
Expand Down
30 changes: 30 additions & 0 deletions docs/12-configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,36 @@ Access via API: `/api/v2/settings/`
| `LOG_AGGREGATOR_ENABLED` | `False` | Enable log forwarding |
| `LOG_AGGREGATOR_LEVEL` | `WARNING` | Minimum level |

### Multi-tenancy & isolation (`/api/v2/settings/system/`)

Every isolation control is **off by default** to preserve single-tenant
backwards compatibility. A multi-tenant deployment must enable at least
`TENANCY_ENABLED` + `TENANCY_RLS_ENABLED`, and `TENANCY_STRICT_ISOLATION_ENABLED`
for hard cross-tenant blocking (vs audit-only).

| Setting | Default | Description |
| ----------------------------------- | ------- | ------------------------------------------------------------------ |
| `TENANCY_ENABLED` | `False` | Master switch for the multi-tenancy feature set |
| `TENANCY_RLS_ENABLED` | `False` | Set the per-request Postgres RLS tenant scope |
| `TENANCY_STRICT_ISOLATION_ENABLED` | `False` | Block cross-tenant API access (HTTP 403) instead of audit-only |
| `TENANCY_RATE_LIMITING_ENABLED` | `False` | Per-tenant token-bucket API rate limiting (needs Redis) |
| `TENANCY_RATE_LIMIT_FAIL_CLOSED` | `False` | Reject requests (503) if the rate-limiter Redis is down, vs allow |
| `TENANCY_DEDICATED_QUEUES_ENABLED` | `False` | Route tenant jobs to dedicated `tenant-{org_id}` Celery queues |

> When RLS is on, tenant isolation **fails closed**: if the tenant scope cannot
> be installed for a request, the request is aborted rather than run with global
> row visibility.

### `import_from_awx` security flags

The AWX importer treats the source as untrusted by default:

| Flag | Effect |
| -------------------- | -------------------------------------------------------------------------- |
| `--grant-superusers` | Honour `is_superuser` / system-role grants from the source (off by default) |
| `--trust-injectors` | Import custom credential-type injectors verbatim (off; else re-approve) |
| `AWX_TOKEN` / `AWX_PASSWORD` | Preferred over `--token` / `--password` (argv leaks via `ps`/`/proc`) |

---

## Common Configuration Tasks
Expand Down
35 changes: 35 additions & 0 deletions docs/22-multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,41 @@ All four live under the **System** category and show up in

---

## Security posture (v2 hardening)

The v2 isolation stack is designed to **fail closed** and is off by default so
single-tenant installs are unaffected. A multi-tenant deployment enables at
least `TENANCY_ENABLED` + `TENANCY_RLS_ENABLED`, plus
`TENANCY_STRICT_ISOLATION_ENABLED` for hard cross-tenant blocking.

- **RLS gate fails closed.** If the per-request Postgres tenant scope cannot be
installed, the request is aborted (HTTP 500) rather than run with global row
visibility (an unset scope makes RLS treat all rows as visible).
- **Strict gate sees cross-tenant objects.** The 403 gate resolves the target
resource's organization with the caller's RLS scope removed — otherwise a
genuinely cross-tenant object is invisible to the lookup and could never be
blocked. When a covered resource's org can't be determined, it defaults to
**deny**.
- **RLS coverage.** Every table with its own `organization_id` has a policy
(including `main_eventlog`); child tables of `main_unifiedjobtemplate` /
`main_unifiedjob` (projects, workflow templates, schedules, nodes, job events)
are covered indirectly through the parent via the multi-table-inheritance join.
Policies cast the tenant GUC as `NULLIF(current_setting(...), '')::int` so the
empty "no scope" sentinel can't raise.
- **Rate limiter** logs Redis outages loudly and can be made to fail closed with
`TENANCY_RATE_LIMIT_FAIL_CLOSED` (default open, favouring availability).
- **Provisioning** refuses to silently reuse an existing username (which would
discard the supplied password and cross-link accounts) unless
`attach_existing_admin` is set.

| Key | Default | Notes |
| ---------------------------------- | ------- | --------------------------------------------------------- |
| `TENANCY_RLS_ENABLED` | `False` | Install the per-request RLS tenant scope |
| `TENANCY_STRICT_ISOLATION_ENABLED` | `False` | Block (403) cross-tenant access instead of audit-only |
| `TENANCY_RATE_LIMIT_FAIL_CLOSED` | `False` | Reject (503) when the rate-limiter Redis is down |

---

## REST API

All `/api/v2/tenants/*` endpoints require `is_superuser`. `/api/v2/branding/`
Expand Down
13 changes: 11 additions & 2 deletions docs/24-awx-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,21 @@ forail-manage import_from_awx \
| Option | Description |
| ----------------------- | ---------------------------------------------------------------------- |
| `--url` | Base URL of the source AWX install (required). |
| `--token` | OAuth2 token for the source AWX API (preferred auth). |
| `--username/--password` | Basic auth, if no token. |
| `--token` | OAuth2 token for the source AWX API (preferred auth). Prefer `AWX_TOKEN` env. |
| `--username/--password` | Basic auth, if no token. Prefer `AWX_USERNAME` / `AWX_PASSWORD` env. |
| `--insecure` | Skip source TLS certificate verification. |
| `--dry-run` | Fetch and report what would change, then roll back without writing. |
| `--grant-superusers` | Honour `is_superuser` / system-role grants from the source (**off** by default). |
| `--trust-injectors` | Import custom credential-type injectors verbatim (**off**; else re-approve). |
| `--resource <type>` | Limit to specific resource type(s); repeatable. Default: all. |

> **The source is treated as untrusted by default.** Superuser promotion and
> custom credential-type injectors (which render into env/extra-vars/files at
> job-execution time) are **not** applied unless you opt in with
> `--grant-superusers` / `--trust-injectors`. Pass secrets via `AWX_TOKEN` /
> `AWX_PASSWORD` environment variables rather than the command line, where they
> would be visible in `ps` / `/proc`.

Resource types (and import order): `organizations`, `users`, `teams`,
`credential_types`, `credentials`, `projects`, `inventories`, `groups`,
`hosts`, `inventory_sources`, `job_templates`, `workflow_job_templates`,
Expand Down
25 changes: 25 additions & 0 deletions forail/main/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,16 @@ def csrf_trusted_origins_validate(serializer, attrs):
)

# --- Multi-Tenancy v2 settings -----------------------------------------------
#
# needtofix M16: every tenant-isolation control below defaults to False, so a
# deployment gets NO cross-tenant enforcement until each flag is turned on. A
# multi-tenant install MUST enable, at minimum:
# TENANCY_ENABLED=True, TENANCY_RLS_ENABLED=True
# and, for hard cross-tenant blocking (vs audit-only):
# TENANCY_STRICT_ISOLATION_ENABLED=True (plus per-org tenant_isolation_strict)
# Rate limiting and dedicated queues are opt-in performance/abuse controls.
# The defaults stay False to preserve single-tenant backwards compatibility;
# the deployment docs call out the required enablement set for tenants.

register(
'TENANCY_RLS_ENABLED',
Expand Down Expand Up @@ -1241,6 +1251,21 @@ def csrf_trusted_origins_validate(serializer, attrs):
category_slug='system',
)

register(
'TENANCY_RATE_LIMIT_FAIL_CLOSED',
field_class=fields.BooleanField,
default=False,
label=_('Rate Limiter Fails Closed'),
help_text=_(
'When True, tenant API requests are rejected with HTTP 503 if the '
'rate-limiter backend (Redis) is unavailable, instead of being allowed '
'through (fail-open). Defaults to False to keep the API available '
'during a Redis outage.'
),
category=_('System'),
category_slug='system',
)

register(
'TENANCY_DEFAULT_API_RATE_LIMIT',
field_class=fields.IntegerField,
Expand Down
92 changes: 78 additions & 14 deletions forail/main/management/commands/import_from_awx.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

import json
import logging
import os

import requests

Expand Down Expand Up @@ -128,23 +129,56 @@ class Command(BaseCommand):

def add_arguments(self, parser):
parser.add_argument('--url', required=True, help='Base URL of the source AWX install, e.g. https://awx.example.com')
parser.add_argument('--token', help='OAuth2 token for the source AWX API (preferred).')
parser.add_argument('--username', help='Username for basic auth (if no token).')
parser.add_argument('--password', help='Password for basic auth (if no token).')
parser.add_argument('--token', help='OAuth2 token for the source AWX API (preferred). '
'Prefer the AWX_TOKEN env var — CLI args are visible in ps/proc.')
parser.add_argument('--username', help='Username for basic auth (if no token). Env: AWX_USERNAME.')
parser.add_argument('--password', help='Password for basic auth (if no token). '
'Prefer the AWX_PASSWORD env var — CLI args are visible in ps/proc.')
parser.add_argument('--insecure', action='store_true', help='Do not verify the source TLS certificate.')
parser.add_argument('--dry-run', action='store_true', help='Fetch and report what would change, then roll back without writing.')
parser.add_argument('--grant-superusers', action='store_true',
help='Honour is_superuser / system_administrator / system_auditor from the source. '
'OFF by default: superuser promotion from a remote source is a privilege-escalation '
'vector, so these grants are skipped and reported unless you opt in explicitly.')
parser.add_argument('--trust-injectors', action='store_true',
help='Import custom credential-type injectors verbatim. OFF by default: injector templates '
'render into env/extra-vars/files at job-execution time, so an untrusted source could '
'ship an injector that runs attacker code. Without this flag the type is imported '
'without its injectors and an admin must re-approve them.')
parser.add_argument('--resource', action='append', choices=RESOURCE_ORDER,
help='Limit to specific resource type(s); may be repeated. Default: all.')

def handle(self, *args, **options):
if not options.get('token') and not options.get('username'):
raise CommandError('Provide --token, or --username/--password.')
# M3: prefer secrets from the environment; CLI args leak via ps/proc,
# shell history and process accounting.
token = options.get('token') or os.environ.get('AWX_TOKEN')
username = options.get('username') or os.environ.get('AWX_USERNAME')
password = options.get('password') or os.environ.get('AWX_PASSWORD')
if options.get('token') or options.get('password'):
self.stderr.write(self.style.WARNING(
'Passing --token/--password on the command line is insecure (visible in '
'ps/proc and shell history). Prefer AWX_TOKEN / AWX_PASSWORD env vars.'))

if not token and not username:
raise CommandError('Provide a token (--token or AWX_TOKEN), or a username '
'(--username/AWX_USERNAME with --password/AWX_PASSWORD).')

# L1: --insecure sends the token / basic-auth credentials over a
# connection with no certificate verification.
if options.get('insecure'):
self.stderr.write(self.style.WARNING(
'--insecure disables TLS verification; credentials are sent over an '
'unauthenticated channel. Use only against a trusted network.'))

# M1/M2: carry the trust opt-ins on the context so importers can gate.
self.grant_superusers = options.get('grant_superusers', False)
self.trust_injectors = options.get('trust_injectors', False)

client = AWXClient(
options['url'],
token=options.get('token'),
username=options.get('username'),
password=options.get('password'),
token=token,
username=username,
password=password,
verify=not options.get('insecure'),
)
# Fail fast on connectivity / auth before opening a transaction.
Expand Down Expand Up @@ -247,8 +281,19 @@ def _import_users(self, client, ctx):
# not a superuser must not silently demote — and potentially lock you
# out of — your own Forail bootstrap admin. System-role grants are
# (re-)applied in _import_roles.
#
# M1: superuser is a remote-controlled privilege-escalation vector.
# A compromised/malicious source could flag arbitrary usernames as
# superusers. Gate it behind an explicit --grant-superusers opt-in
# and log every promotion loudly.
if u.get('is_superuser'):
obj.is_superuser = True
if self.grant_superusers:
if not obj.is_superuser:
ctx.warn('GRANTING superuser to "%s" from source (--grant-superusers).' % u['username'])
obj.is_superuser = True
else:
ctx.warn('Skipped superuser grant for "%s" (source flagged it superuser; '
'pass --grant-superusers to honour it).' % u['username'])
if created:
obj.set_unusable_password()
ctx.warn('User "%s" created without a password — set one (passwords are not exported by AWX).' % u['username'])
Expand Down Expand Up @@ -278,7 +323,18 @@ def _import_credential_types(self, client, ctx):
obj.description = ct.get('description', '') or ''
obj.kind = ct.get('kind', obj.kind)
obj.inputs = ct.get('inputs', {}) or {}
obj.injectors = ct.get('injectors', {}) or {}
# M2: a custom credential type's injectors are rendered into env
# vars / extra-vars / files at job-execution time. Importing them
# verbatim from an untrusted source is a post-migration RCE vector.
# Skip injectors unless the operator explicitly trusts the source;
# an admin re-approves the injector bodies afterwards.
source_injectors = ct.get('injectors', {}) or {}
if source_injectors and not self.trust_injectors:
obj.injectors = {}
ctx.warn('Credential type "%s": injectors NOT imported (re-approve manually, '
'or re-run with --trust-injectors).' % ct['name'])
else:
obj.injectors = source_injectors
self._save(ctx, 'credential_type', obj, created)
ctx.maps['credential_type'][ct['id']] = obj

Expand Down Expand Up @@ -626,10 +682,18 @@ def _import_roles(self, client, ctx):
user = ctx.maps['user'].get(u['id'])
if user is None:
continue
if role_field == 'system_administrator':
user.is_superuser = True
elif role_field == 'system_auditor':
user.is_system_auditor = True
# M1: gate remote system-role promotion behind opt-in.
if role_field in ('system_administrator', 'system_auditor'):
if not self.grant_superusers:
ctx.warn('Skipped %s grant for "%s" (pass --grant-superusers to honour it).'
% (role_field, user.username))
continue
if role_field == 'system_administrator':
ctx.warn('GRANTING system_administrator (superuser) to "%s".' % user.username)
user.is_superuser = True
else:
ctx.warn('GRANTING system_auditor to "%s".' % user.username)
user.is_system_auditor = True
else:
continue
if not ctx.dry_run:
Expand Down
45 changes: 45 additions & 0 deletions forail/main/migrations/0209_rls_eventlog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Multi-Tenancy v2: extend RLS coverage to main_eventlog.

needtofix M4 — ``EventLog`` carries its own ``organization_id`` but was
omitted from the original RLS policy set (0206). Add the policy here.

Idempotent by construction: the CREATE is preceded by DROP POLICY IF EXISTS,
so this converges whether or not 0206 already created the policy (a fresh
install runs 0206 with main_eventlog already in RLS_TABLES_DIRECT).
"""

from django.db import migrations

from forail.main.tenancy.helpers import build_rls_policy_sql


_TABLE = 'main_eventlog'


def _forward_sql():
create, drop = build_rls_policy_sql(_TABLE, 'organization_id')
return '\n'.join([
f'ALTER TABLE {_TABLE} ENABLE ROW LEVEL SECURITY;',
f'ALTER TABLE {_TABLE} FORCE ROW LEVEL SECURITY;',
drop, # drop first so re-create is safe on fresh installs
create,
])


def _reverse_sql():
_, drop = build_rls_policy_sql(_TABLE, 'organization_id')
return '\n'.join([
drop,
f'ALTER TABLE {_TABLE} DISABLE ROW LEVEL SECURITY;',
])


class Migration(migrations.Migration):

dependencies = [
('main', '0208_driftalertrule_audit_fields'),
]

operations = [
migrations.RunSQL(sql=_forward_sql(), reverse_sql=_reverse_sql()),
]
Loading
Loading