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
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""change_brand_manifest_policy_default_to_require_auth

Revision ID: 378299ad502f
Revises: 6f05f4179c33
Create Date: 2025-10-29 02:52:27.162501

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "378299ad502f"
down_revision: str | Sequence[str] | None = "6f05f4179c33"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema - change brand_manifest_policy default to require_auth.

This reflects the correct default for most publishers: standard B2B model
where advertisers must sign up to see products and pricing.

Existing tenants keep their current settings (no data migration).
"""
# Change the server default for new tenants
op.alter_column(
"tenants",
"brand_manifest_policy",
server_default="require_auth",
existing_type=sa.String(50),
existing_nullable=False,
comment="Product discovery access policy: require_auth (standard B2B - signup to see pricing), require_brand (brand context required for bespoke products), public (generic products visible to all)",
)


def downgrade() -> None:
"""Downgrade schema - revert to require_brand default."""
op.alter_column(
"tenants",
"brand_manifest_policy",
server_default="require_brand",
existing_type=sa.String(50),
existing_nullable=False,
comment="Brand manifest requirement policy: public (no auth, no pricing), require_auth (auth required, no brand manifest), require_brand (auth + brand manifest required)",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""add_brand_manifest_policy_to_tenants

Revision ID: 6f05f4179c33
Revises: 319e6b366151
Create Date: 2025-10-28 18:27:53.361639

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "6f05f4179c33"
down_revision: str | Sequence[str] | None = "319e6b366151"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# Add brand_manifest_policy column with default 'require_brand'
# This preserves existing behavior (strictest policy) for current tenants
op.add_column(
"tenants",
sa.Column(
"brand_manifest_policy",
sa.String(50),
nullable=False,
server_default="require_brand",
comment="Brand manifest requirement policy: public (no auth, no pricing), require_auth (auth required, no brand manifest), require_brand (auth + brand manifest required)",
),
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_column("tenants", "brand_manifest_policy")
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ services:
GAM_OAUTH_CLIENT_SECRET: ${GAM_OAUTH_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-}
APPROXIMATED_API_KEY: ${APPROXIMATED_API_KEY:-}
APPROXIMATED_PROXY_IP: ${APPROXIMATED_PROXY_IP:-37.16.24.200}
APPROXIMATED_BACKEND_URL: ${APPROXIMATED_BACKEND_URL:-adcp-sales-agent.fly.dev}
Expand Down
9 changes: 5 additions & 4 deletions schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"etag": "W/\"68ffaa97-1ce0\"",
"last-modified": "Mon, 27 Oct 2025 17:23:35 GMT",
"downloaded_at": "2025-10-27T20:50:42.491975",
"schema_ref": "/schemas/v1/core/webhook-payload.json"
"etag": "W/\"69013dbf-1ce0\"",
"last-modified": "Tue, 28 Oct 2025 22:03:43 GMT",
"downloaded_at": "2025-10-28T18:20:43.362874",
"schema_ref": "/schemas/v1/core/webhook-payload.json",
"content_hash": "3037a3a80eb9e20dc2034a6701bf871e944f910e3ea1fec0885361ed06ace8eb"
}
9 changes: 5 additions & 4 deletions schemas/v1/_schemas_v1_enums_task-type_json.json.meta
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"etag": "W/\"68ffaa97-531\"",
"last-modified": "Mon, 27 Oct 2025 17:23:35 GMT",
"downloaded_at": "2025-10-27T20:50:43.126772",
"schema_ref": "/schemas/v1/enums/task-type.json"
"etag": "W/\"69013dbf-531\"",
"last-modified": "Tue, 28 Oct 2025 22:03:43 GMT",
"downloaded_at": "2025-10-28T18:20:43.896965",
"schema_ref": "/schemas/v1/enums/task-type.json",
"content_hash": "7d0bd6c33ff8e507f1b2c642c00f92bbbd30f4db489f9be7fe07a10276ee604f"
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@
"additionalProperties": false
}
},
"required": [
"brand_manifest"
],
"required": [],
"additionalProperties": false
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"etag": "W/\"68ffaa97-7b9\"",
"last-modified": "Mon, 27 Oct 2025 17:23:35 GMT",
"downloaded_at": "2025-10-28T16:15:28.603429",
"etag": "W/\"69013dbf-7a1\"",
"last-modified": "Tue, 28 Oct 2025 22:03:43 GMT",
"downloaded_at": "2025-10-28T18:20:43.943318",
"schema_ref": "/schemas/v1/media-buy/get-products-request.json",
"content_hash": "3de0c3f8a90fb2f1aacc80c973ccebe53737107fcea7c64abb69a33eaa3c7caf"
"content_hash": "1aee2a71dbf4212648696c52b98d69be4f9678bb12fed78b093357337e0a47c0"
}
11 changes: 11 additions & 0 deletions src/admin/blueprints/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def index(tenant_id):
policy_settings = default_policies.copy()
policy_settings.update(tenant_policies)

# Get brand manifest policy
brand_manifest_policy = tenant.brand_manifest_policy

# Get recent policy checks from audit log
stmt = (
select(AuditLog)
Expand Down Expand Up @@ -124,6 +127,7 @@ def index(tenant_id):
tenant_id=tenant_id,
tenant_name=tenant_name,
policy_settings=policy_settings,
brand_manifest_policy=brand_manifest_policy,
recent_checks=recent_checks,
pending_reviews=pending_reviews,
)
Expand Down Expand Up @@ -186,11 +190,18 @@ def parse_textarea_lines(field_name):

config["policy_settings"] = policy_settings

# Get brand_manifest_policy from form
brand_manifest_policy = request.form.get("brand_manifest_policy", "require_auth")
# Validate policy value
if brand_manifest_policy not in ["public", "require_auth", "require_brand"]:
brand_manifest_policy = "require_auth" # Default to standard B2B model

# Update database
with get_db_session() as db_session:
tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first()
if tenant:
tenant.policy_settings = json.dumps(policy_settings)
tenant.brand_manifest_policy = brand_manifest_policy
db_session.commit()

return redirect(url_for("policy.index", tenant_id=tenant_id))
Expand Down
6 changes: 6 additions & 0 deletions src/core/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ class Tenant(Base, JSONValidatorMixin):
nullable=True,
comment="Advertising policy configuration with prohibited categories, tactics, and advertisers",
)
brand_manifest_policy: Mapped[str] = mapped_column(
String(50),
nullable=False,
server_default="require_auth",
comment="Product discovery access policy: require_auth (standard B2B - signup to see pricing), require_brand (brand context required for bespoke products), public (generic products visible to all)",
)

# Naming templates (business rules - shared across all adapters)
order_name_template: Mapped[str | None] = mapped_column(
Expand Down
70 changes: 63 additions & 7 deletions src/core/tools/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,45 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context)
principal = get_principal_object(principal_id) if principal_id else None
principal_data = principal.model_dump() if principal else None

# Extract offering text from brand_manifest
# Get tenant's brand manifest policy
brand_manifest_policy = tenant.get("brand_manifest_policy", "require_auth")

# Validate brand manifest based on policy
# Policy options:
# - "public": No auth required, no brand_manifest required (but no pricing shown)
# - "require_auth": Auth required, brand_manifest optional
# - "require_brand": Auth + brand_manifest both required (strictest, default)

if brand_manifest_policy == "require_brand":
# Strictest: require both auth and brand_manifest
if not principal_id:
raise ToolError("Authentication required. This tenant requires authentication to access product catalog.")
if not req.brand_manifest:
raise ToolError(
"brand_manifest required. This tenant requires brand information to provide product recommendations."
)
elif brand_manifest_policy == "require_auth":
# Middle: require auth but brand_manifest is optional
if not principal_id:
raise ToolError("Authentication required. This tenant requires authentication to access product catalog.")
# brand_manifest is optional in this mode
elif brand_manifest_policy == "public":
# Most permissive: no auth required, no brand_manifest required
# Pricing will be stripped later for anonymous users
pass
else:
# Unknown policy - default to strictest for safety
logger.warning(
f"Unknown brand_manifest_policy '{brand_manifest_policy}' for tenant {tenant['tenant_id']}, defaulting to require_brand"
)
if not principal_id:
raise ToolError("Authentication required. This tenant requires authentication to access product catalog.")
if not req.brand_manifest:
raise ToolError(
"brand_manifest required. This tenant requires brand information to provide product recommendations."
)

# Extract offering text from brand_manifest (if provided)
offering = None
if req.brand_manifest:
if isinstance(req.brand_manifest, str):
Expand All @@ -110,8 +148,9 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context)
elif isinstance(req.brand_manifest, dict):
offering = req.brand_manifest.get("name", "")

# If no brand_manifest provided, use a generic offering for context
if not offering:
raise ToolError("brand_manifest must provide brand information")
offering = "Generic advertising campaign"

# Skip strict validation in test environments (allow simple test values)

Expand Down Expand Up @@ -490,10 +529,25 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context)
except Exception as e:
logger.warning(f"Failed to annotate pricing options with adapter support: {e}")

# Filter pricing data for anonymous users
if principal_id is None: # Anonymous user
# Remove pricing data from products for anonymous users
# Set to empty list to hide pricing (will be excluded during serialization)
# Filter pricing data based on policy and authentication
# Policy determines who can see pricing:
# - "public": No pricing for anyone (including authenticated users)
# - "require_auth": Pricing only for authenticated users
# - "require_brand": Pricing only for authenticated users with brand_manifest

should_show_pricing = False
if brand_manifest_policy == "public":
# Public policy: never show pricing (encourages users to authenticate for pricing)
should_show_pricing = False
elif brand_manifest_policy == "require_auth":
# Require auth: show pricing only if authenticated
should_show_pricing = principal_id is not None
elif brand_manifest_policy == "require_brand":
# Require brand: show pricing only if authenticated AND brand_manifest provided
should_show_pricing = principal_id is not None and req.brand_manifest is not None

if not should_show_pricing:
# Remove pricing data from products
for product in modified_products:
product.pricing_options = []

Expand Down Expand Up @@ -634,7 +688,9 @@ def safe_json_parse(value):

# Convert pricing_options ORM objects to Pydantic objects
pricing_options = []
logger.info(f"Product {product.name} ({product.product_id}) has {len(product.pricing_options)} pricing options loaded")
logger.info(
f"Product {product.name} ({product.product_id}) has {len(product.pricing_options)} pricing options loaded"
)
for po in product.pricing_options:
fixed_str = "fixed" if po.is_fixed else "auction"
pricing_option_data = {
Expand Down
23 changes: 23 additions & 0 deletions templates/policy_settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ <h5 class="mb-0">Policy Configuration</h5>
</small>
</div>

<hr class="my-4">

<div class="form-group mb-3">
<label for="brand_manifest_policy"><strong>Product Discovery Access</strong></label>
<select class="form-control" id="brand_manifest_policy" name="brand_manifest_policy">
<option value="require_auth" {% if brand_manifest_policy == 'require_auth' %}selected{% endif %}>
Standard B2B (recommended)
</option>
<option value="require_brand" {% if brand_manifest_policy == 'require_brand' %}selected{% endif %}>
Brand context required
</option>
<option value="public" {% if brand_manifest_policy == 'public' %}selected{% endif %}>
Public marketplace
</option>
</select>
<small class="form-text text-muted">
<strong>Standard B2B (recommended):</strong> Advertisers sign up to see products and pricing. Most common model.<br>
<strong>Brand context required:</strong> Advertisers provide brand info before seeing products. For highly customized/bespoke products.<br>
<strong>Public marketplace:</strong> Products visible to everyone (pricing hidden). For generic, commoditized ad products.<br>
<em>Note: Future versions will support per-product settings. Currently applies to all products.</em>
</small>
</div>

<button type="submit" class="btn btn-primary">Save Settings</button>
<a href="/tenant/{{ tenant_id }}/policy/rules" class="btn btn-secondary">
Manage Custom Rules
Expand Down
18 changes: 18 additions & 0 deletions templates/policy_settings_comprehensive.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ <h5 class="mb-0">General Settings</h5>
Create review tasks for policy violations instead of auto-rejecting
</small>
</div>

<hr class="my-4">

<div class="form-group mb-3">
<label for="brand_manifest_policy"><strong>Who can browse your ad products?</strong></label>
<select class="form-control" id="brand_manifest_policy" name="brand_manifest_policy">
<option value="require_auth" {% if brand_manifest_policy == 'require_auth' or brand_manifest_policy == 'require_brand' or not brand_manifest_policy %}selected{% endif %}>
Authenticated buyers only
</option>
<option value="public" {% if brand_manifest_policy == 'public' %}selected{% endif %}>
Public access (no pricing shown)
</option>
</select>
<small class="form-text text-muted">
<strong>Authenticated buyers:</strong> Advertisers must sign up to see products and pricing.<br>
<strong>Public access:</strong> Anyone can see what you offer (pricing requires signup).
</small>
</div>
</div>
</div>

Expand Down
Loading