Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
12 changes: 11 additions & 1 deletion .github/workflows/app-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,18 @@ jobs:
- name: Run frontend lint
run: cd frontend && npm run lint

- name: Cache Next.js build
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: frontend/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('frontend/package-lock.json') }}-${{ hashFiles('frontend/**/*.js', 'frontend/**/*.jsx', 'frontend/**/*.ts', 'frontend/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('frontend/package-lock.json') }}-

- name: Build frontend
env:
NEXT_TELEMETRY_DISABLED: "1"
NODE_OPTIONS: "--max-old-space-size=4096"
run: cd frontend && npm run build
run: |
rm -f ~/package-lock.json
cd frontend && npm run build
20 changes: 10 additions & 10 deletions .github/workflows/strix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,17 @@ jobs:
- name: Gate Strix secrets
id: gate
env:
STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5' }}
STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5.4' }}
STRIX_OPENAI_API_KEY: ${{ secrets.STRIX_OPENAI_API_KEY }}
run: |
strix_model="$(printf '%s' "$STRIX_GITHUB_MODELS_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
case "$strix_model" in
openai/gpt-5 | openai/gpt-5-* | openai/gpt-5.[0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \
openai/openai/gpt-5 | openai/openai/gpt-5-* | openai/openai/gpt-5.[0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*)
openai/gpt-5.[4-9]* | openai/gpt-5.[1-9][0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]* | \
openai/openai/gpt-5.[4-9]* | openai/openai/gpt-5.[1-9][0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*)
echo 'enabled=true' >> "$GITHUB_OUTPUT"
;;
*)
echo '::error::STRIX_LLM must select an OpenAI GPT-5 or newer model, for example openai/gpt-5.'
echo '::error::STRIX_LLM must select an OpenAI GPT-5.4 or newer model, for example openai/gpt-5.4.'
exit 1
;;
esac
Expand All @@ -118,7 +118,7 @@ jobs:
id: auth_gate
if: steps.gate.outputs.enabled == 'true'
env:
STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5' }}
STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5.4' }}
DEFAULT_PROVIDER: openai
run: |
. "$TRUSTED_WORKSPACE/scripts/ci/strix_model_utils.sh"
Expand Down Expand Up @@ -228,7 +228,7 @@ jobs:
- name: Prepare Strix model input file
if: steps.gate.outputs.enabled == 'true' && steps.auth_gate.outputs.can_run == 'true'
env:
STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5' }}
STRIX_GITHUB_MODELS_MODEL: ${{ secrets.STRIX_LLM || 'openai/gpt-5.4' }}
run: |
umask 077
strix_llm_file="$RUNNER_TEMP/strix_llm.txt"
Expand All @@ -242,20 +242,20 @@ jobs:
printf '%s' "$strix_model" > "$strix_llm_file"
;;
*)
echo '::error::STRIX_LLM must select an OpenAI GPT-5 or newer model, for example openai/gpt-5.'
echo '::error::STRIX_LLM must select an OpenAI GPT-5.4 or newer model, for example openai/gpt-5.4.'
exit 1
;;
esac
else
case "$strix_model" in
openai/gpt-5 | openai/gpt-5-* | openai/gpt-5.[0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]*)
openai/gpt-5.[4-9]* | openai/gpt-5.[1-9][0-9]* | openai/gpt-[6-9]* | openai/gpt-[1-9][0-9]*)
printf 'openai/%s' "$strix_model" > "$strix_llm_file"
;;
openai/openai/gpt-5 | openai/openai/gpt-5-* | openai/openai/gpt-5.[0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*)
openai/openai/gpt-5.[4-9]* | openai/openai/gpt-5.[1-9][0-9]* | openai/openai/gpt-[6-9]* | openai/openai/gpt-[1-9][0-9]*)
printf '%s' "$strix_model" > "$strix_llm_file"
;;
*)
echo '::error::STRIX_LLM must select a GitHub Models OpenAI GPT-5 or newer model, for example openai/gpt-5.'
echo '::error::STRIX_LLM must select a GitHub Models OpenAI GPT-5.4 or newer model, for example openai/gpt-5.4.'
exit 1
;;
esac
Expand Down
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
explicit `if: ${{ always() }}` upload steps when needed.
- Prefer upgrading or removing vulnerable dependencies over downgrading patched
packages unless compatibility evidence is recorded in the PR.
- Strix Security Scan must use an OpenAI GPT-5-or-newer model. Prefer GitHub
- Strix Security Scan must use an OpenAI GPT-5.4-or-newer model. Prefer GitHub
Models with `models: read`, but verify actual inference availability, not only
catalog presence. If the repo token cannot invoke GitHub Models GPT-5, use
only an explicitly named `STRIX_OPENAI_API_KEY` direct GPT-5 credential rather
catalog presence. If the repo token cannot invoke GitHub Models GPT-5.4, use
only an explicitly named `STRIX_OPENAI_API_KEY` direct GPT-5.4 credential rather
than downgrading to Gemini, GPT-4o, or GPT-4.1; record the inference evidence
in the PR. Do not repurpose generic `LLM_API_KEY` for direct OpenAI Strix
calls, because it may hold a GitHub Models token or PAT.
Expand Down
69 changes: 60 additions & 9 deletions backend/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,35 @@
from dataclasses import dataclass
from typing import Annotated, Any, Literal, cast

import jwt
from jwt import PyJWKClient
from fastapi import Depends, Header, HTTPException

from core.config import settings, validate_auth_session_hmac_secret_value

RoleName = Literal["platform_admin", "organization_admin", "group_admin", "member"]
jwks_client = None
if settings.OIDC_JWKS_URL:
jwks_client = PyJWKClient(settings.OIDC_JWKS_URL)

RoleName = Literal[
"system_admin",
"tenant_admin",
"platform_admin",
"organization_admin",
"group_admin",
"member",
]
ALLOWED_ROLES: set[str] = {
"system_admin",
"tenant_admin",
"platform_admin",
"organization_admin",
"group_admin",
"member",
}
SYSTEM_ADMIN_ROLES = frozenset({"system_admin", "platform_admin"})
TENANT_ADMIN_ROLES = frozenset({"tenant_admin", "organization_admin"})
ADMIN_ROLES = SYSTEM_ADMIN_ROLES | TENANT_ADMIN_ROLES
SESSION_ISSUER = "naruon-control-plane"
SESSION_AUDIENCE = "naruon-api"
SESSION_SIGNING_ALGORITHM = "HS256"
Expand All @@ -35,14 +53,26 @@ class AuthContext:


def ensure_organization_access(auth_context: AuthContext, organization_id: str) -> None:
if auth_context.role == "platform_admin":
if is_system_admin_role(auth_context.role):
return
if auth_context.organization_id != organization_id:
raise HTTPException(
status_code=403, detail="Resource belongs to a different organization"
)


def is_system_admin_role(role: str) -> bool:
return role in SYSTEM_ADMIN_ROLES


def is_tenant_admin_role(role: str) -> bool:
return role in TENANT_ADMIN_ROLES


def is_admin_role(role: str) -> bool:
return role in ADMIN_ROLES


async def get_auth_context(
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
) -> AuthContext:
Expand Down Expand Up @@ -120,6 +150,25 @@ def _json_object_from_base64url_segment(segment: str) -> dict[str, Any]:

def _verify_signed_session_payload(authorization: str | None) -> dict[str, Any]:
token = _extract_bearer_token(authorization)

# OIDC RS256 verification is authoritative when configured.
if settings.OIDC_ISSUER_URL:
if jwks_client is None:
raise _authentication_error()
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=settings.OIDC_CLIENT_ID or SESSION_AUDIENCE,
issuer=settings.OIDC_ISSUER_URL
)
return payload
except Exception:
raise _authentication_error() from None

# Legacy HMAC HS256 Fallback
token_segments = token.split(".")
if len(token_segments) != 3:
raise _authentication_error()
Expand Down Expand Up @@ -172,12 +221,14 @@ def _tuple_string_claim(payload: dict[str, Any], name: str) -> tuple[str, ...]:


def _validate_session_metadata(payload: dict[str, Any]) -> None:
if payload.get("ver") != 1:
raise _authentication_error()
if payload.get("iss") != SESSION_ISSUER:
raise _authentication_error()
if payload.get("aud") != SESSION_AUDIENCE:
raise _authentication_error()
# If OIDC is configured, the issuer/audience might be verified by jwt.decode
if not settings.OIDC_ISSUER_URL:
if payload.get("ver") != 1:
raise _authentication_error()
if payload.get("iss") != SESSION_ISSUER:
raise _authentication_error()
if payload.get("aud") != SESSION_AUDIENCE:
raise _authentication_error()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
expires_at = payload.get("exp")
if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)):
raise _authentication_error()
Expand All @@ -194,7 +245,7 @@ def _auth_context_from_session_payload(payload: dict[str, Any]) -> AuthContext:
raise _authentication_error()
role = cast(RoleName, role_value)
organization_id = _optional_string_claim(payload, "org")
if role != "platform_admin" and organization_id is None:
if not is_system_admin_role(role) and organization_id is None:
raise _authentication_error()
return AuthContext(
user_id=_required_string_claim(payload, "sub"),
Expand Down
11 changes: 8 additions & 3 deletions backend/api/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from api.auth import AuthContext, get_auth_context
from api.auth import (
AuthContext,
get_auth_context,
is_system_admin_role,
is_tenant_admin_role,
)
from services.calendar_service import create_calendar_event, validate_calendar_todo_text
from services.exceptions import CalendarServiceError, UnsafeCalendarTodoError

Expand Down Expand Up @@ -113,11 +118,11 @@ def _find_writeback_source_by_id(
def _can_target_writeback_source(
target_source: WritebackSource, auth_context: AuthContext
) -> bool:
if auth_context.role == "platform_admin":
if is_system_admin_role(auth_context.role):
return True
if target_source.organization_id != auth_context.organization_id:
return False
if auth_context.role == "organization_admin":
if is_tenant_admin_role(auth_context.role):
return True
return target_source.owner_id == auth_context.user_id

Expand Down
4 changes: 2 additions & 2 deletions backend/api/llm_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from api.auth import AuthContext, get_auth_context
from api.auth import AuthContext, get_auth_context, is_admin_role
from db.models import AuditLog, LLMProvider
from db.session import get_db
from services.llm_provider_urls import (
Expand Down Expand Up @@ -65,7 +65,7 @@ async def _validated_base_url(value: str | None) -> str | None:
async def check_admin_access(
auth_context: AuthContext = Depends(get_auth_context),
) -> AuthContext:
if auth_context.role not in {"platform_admin", "organization_admin"}:
if not is_admin_role(auth_context.role):
raise HTTPException(
status_code=403, detail="Organization admin access required"
)
Expand Down
7 changes: 7 additions & 0 deletions backend/api/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@

class RelationshipResponse(BaseModel):
sender_email: str
parent_sender_email: str | None = None
relationship_type: str
confidence_score: float
next_action: str
action_reason: str

class RelationshipCreate(BaseModel):
sender_email: str
parent_sender_email: str | None = None
relationship_type: str
confidence_score: float = 1.0

Expand All @@ -35,6 +37,7 @@ async def get_relationships(
return [
RelationshipResponse(
sender_email=r.sender_email,
parent_sender_email=r.parent_sender_email,
relationship_type=r.relationship_type,
confidence_score=r.confidence_score,
**ontology_service.next_action_for_relationship(r.relationship_type),
Expand All @@ -60,10 +63,13 @@ async def create_relationship(
if rel:
rel.relationship_type = req.relationship_type
rel.confidence_score = req.confidence_score
if "parent_sender_email" in req.model_fields_set:
rel.parent_sender_email = req.parent_sender_email
else:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
rel = SenderRelationship(
user_id=user_id,
sender_email=req.sender_email,
parent_sender_email=req.parent_sender_email,
relationship_type=req.relationship_type,
confidence_score=req.confidence_score
)
Expand All @@ -74,6 +80,7 @@ async def create_relationship(

return RelationshipResponse(
sender_email=rel.sender_email,
parent_sender_email=rel.parent_sender_email,
relationship_type=rel.relationship_type,
confidence_score=rel.confidence_score,
**ontology_service.next_action_for_relationship(rel.relationship_type),
Expand Down
12 changes: 9 additions & 3 deletions backend/api/runner_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from api.auth import AuthContext, ensure_organization_access, get_auth_context
from api.auth import (
AuthContext,
ensure_organization_access,
get_auth_context,
is_admin_role,
is_tenant_admin_role,
)
from core.config import settings
from db.models import WorkspaceRunnerConfig
from db.session import get_db
Expand Down Expand Up @@ -43,9 +49,9 @@ def _connector_manifest() -> dict[str, object]:


def _check_org_admin(auth_context: AuthContext = Depends(get_auth_context)) -> AuthContext:
if auth_context.role not in {"platform_admin", "organization_admin"}:
if not is_admin_role(auth_context.role):
raise HTTPException(status_code=403, detail="Organization admin access required")
if auth_context.role == "organization_admin" and not auth_context.organization_id:
if is_tenant_admin_role(auth_context.role) and not auth_context.organization_id:
raise HTTPException(status_code=403, detail="Organization scope is required")
return auth_context

Expand Down
4 changes: 2 additions & 2 deletions backend/api/tenant_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from db.models import TenantConfig
from db.session import get_db
from api.auth import get_current_user, get_current_user_role
from api.auth import get_current_user, get_current_user_role, is_admin_role
from services.email_client import (
validate_imap_destination,
validate_imap_port,
Expand All @@ -23,7 +23,7 @@
async def get_global_config(
role: str = Depends(get_current_user_role)
):
if role not in ["platform_admin", "organization_admin"]:
if not is_admin_role(role):
raise HTTPException(status_code=403, detail="Not enough privileges")
return {"status": "ok", "global_settings": {}}

Expand Down
Loading
Loading