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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ NARUON_BACKEND_HOST_PORT=127.0.0.1:8000
# CORS settings for backend development.
ALLOWED_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8000,http://127.0.0.1:8000

# Comma-separated host allowlist for the scopeweave promote action (SSRF guard).
# The per-workspace scopeweave base URL and PAT are stored encrypted in the
# database (scopeweave_promotion_target); only hosts listed here may be targets.
# Empty (default) disables promotion. Example: scopeweave.example.com
ALLOWED_SCOPEWEAVE_HOSTS=

# Sending is configured per user in tenant_configs. Without real credentials,
# the backend send service returns an explicit simulated result.
SMTP_MODE=simulated
108 changes: 108 additions & 0 deletions backend/alembic/versions/0013_scopeweave_promotion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""add scopeweave promotion target and link tables

Revision ID: 0013_scopeweave_promotion
Revises: 0012_llm_batch_orchestrator
Create Date: 2026-07-08 00:00:00.000000
"""

from alembic import op
import sqlalchemy as sa

revision = "0013_scopeweave_promotion"
down_revision = "0012_llm_batch_orchestrator"

_TARGET_TABLE = "scopeweave_promotion_target"
_LINK_TABLE = "scopeweave_promotion_link"


def upgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)

if not inspector.has_table(_TARGET_TABLE):
op.create_table(
_TARGET_TABLE,
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("organization_id", sa.String(), nullable=True),
sa.Column("workspace_id", sa.String(), nullable=False),
sa.Column("base_url", sa.String(), nullable=False),
sa.Column("access_token", sa.String(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_id",
"workspace_id",
name="uq_scopeweave_promotion_target_scope",
),
)

if not inspector.has_table(_LINK_TABLE):
op.create_table(
_LINK_TABLE,
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("organization_id", sa.String(), nullable=True),
sa.Column("workspace_id", sa.String(), nullable=False),
sa.Column("project_uid", sa.String(), nullable=False),
sa.Column("object_uid", sa.String(), nullable=False),
sa.Column("object_type", sa.String(), nullable=False),
sa.Column("scopeweave_work_item_id", sa.String(), nullable=False),
sa.Column("scopeweave_work_item_url", sa.String(), nullable=True),
sa.Column("promoted_confidence", sa.Float(), nullable=False),
sa.Column("citation_count", sa.Integer(), nullable=False),
sa.Column("promoted_by_user_id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"workspace_id",
"object_uid",
name="uq_scopeweave_promotion_link_object",
),
)

for table_name, indexes in _scopeweave_indexes().items():
for index_name, column_names in indexes:
op.create_index(
index_name,
table_name,
column_names,
if_not_exists=True,
)


def downgrade() -> None:
connection = op.get_bind()
inspector = sa.inspect(connection)

for table_name in (_LINK_TABLE, _TARGET_TABLE):
if inspector.has_table(table_name):
for index_name, _column_names in reversed(
_scopeweave_indexes()[table_name]
):
op.drop_index(index_name, table_name=table_name, if_exists=True)
op.drop_table(table_name)


def _scopeweave_indexes() -> dict[str, list[tuple[str, list[str]]]]:
return {
_TARGET_TABLE: [
("ix_scopeweave_promotion_target_user", ["user_id"]),
(
"ix_scopeweave_promotion_target_scope",
["organization_id", "workspace_id"],
),
],
_LINK_TABLE: [
("ix_scopeweave_promotion_link_user", ["user_id"]),
("ix_scopeweave_promotion_link_object_uid", ["object_uid"]),
("ix_scopeweave_promotion_link_project", ["project_uid"]),
(
"ix_scopeweave_promotion_link_scope",
["organization_id", "workspace_id", "project_uid"],
),
],
}
69 changes: 69 additions & 0 deletions backend/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
get_project_evidence,
get_project_traceability,
)
from services.scopeweave_client import ScopeweaveConfigError, ScopeweavePushError
from services.scopeweave_promotion import (
ScopeweaveNotConfiguredError,
ScopeweavePromotionOutcome,
promote_project_object,
)

router = APIRouter(prefix="/api/projects", tags=["projects"])

Expand Down Expand Up @@ -101,6 +107,23 @@ class ProjectEvidenceResponse(BaseModel):
citation_bundle: list[ProjectCitationResponse]


class ProjectPromoteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

object_uid: str = Field(min_length=1, max_length=160)


class ProjectPromoteResponse(BaseModel):
project_uid: str
object_uid: str
object_type: str
scopeweave_work_item_id: str
scopeweave_work_item_url: str | None
promoted_confidence: float
citation_count: int
created: bool


class ProjectCorrectionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand Down Expand Up @@ -249,6 +272,37 @@ async def apply_project_correction_endpoint(
return _correction_response(correction)


@router.post(
"/{project_uid}/promote",
response_model=ProjectPromoteResponse,
)
async def promote_project_object_endpoint(
project_uid: str,
request: ProjectPromoteRequest,
auth_context: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
):
try:
outcome = await promote_project_object(
db,
scope=_project_scope(auth_context),
project_uid=project_uid,
object_uid=request.object_uid,
actor_user_id=auth_context.user_id,
)
await db.commit()
except ProjectGraphNotFoundError as exc:
await db.rollback()
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ScopeweaveNotConfiguredError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail=str(exc)) from exc
except (ScopeweaveConfigError, ScopeweavePushError) as exc:
await db.rollback()
raise HTTPException(status_code=502, detail=str(exc)) from exc
return _promote_response(outcome)


def _citation_response(citation: ProjectCitation) -> ProjectCitationResponse:
return ProjectCitationResponse(
content_segment_uid=citation.content_segment_uid,
Expand Down Expand Up @@ -338,6 +392,21 @@ def _evidence_response(evidence: ProjectEvidence) -> ProjectEvidenceResponse:
)


def _promote_response(
outcome: ScopeweavePromotionOutcome,
) -> ProjectPromoteResponse:
return ProjectPromoteResponse(
project_uid=outcome.project_uid,
object_uid=outcome.object_uid,
object_type=outcome.object_type,
scopeweave_work_item_id=outcome.scopeweave_work_item_id,
scopeweave_work_item_url=outcome.scopeweave_work_item_url,
promoted_confidence=outcome.promoted_confidence,
citation_count=outcome.citation_count,
created=outcome.created,
)


def _correction_response(correction: ProjectCorrection) -> ProjectCorrectionResponse:
return ProjectCorrectionResponse(
correction_uid=correction.correction_uid,
Expand Down
5 changes: 5 additions & 0 deletions backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ class Settings(BaseSettings):
ALLOWED_POP3_PORTS: str = "995"
ALLOWED_LLM_BASE_URL_HOSTS: str = ""
ALLOW_LOCAL_LLM_PROVIDERS: bool = False
# Host allowlist for the scopeweave promotion target. The per-workspace
# base URL and PAT themselves live encrypted in the database
# (scopeweave_promotion_target); this setting only pins which hosts an
# operator is permitted to promote work items to (SSRF host allowlist).
ALLOWED_SCOPEWEAVE_HOSTS: str = ""
ALLOWED_CORS_ORIGINS: str = ""
ENABLE_PROMETHEUS_METRICS: bool = False
DATA_REGION: str = "kr"
Expand Down
92 changes: 92 additions & 0 deletions backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,98 @@ class LLMProvider(Base):
)


class ScopeweavePromotionTarget(Base):
"""Per-workspace scopeweave instance the promote action pushes work items to.

The ``base_url`` is validated against ``ALLOWED_SCOPEWEAVE_HOSTS`` at runtime
and the personal access token is stored Fernet-encrypted, mirroring how
``LLMProvider`` keeps its ``api_key``. Config is resolved from the database
at request time (never from ``os.getenv``).
"""

__tablename__ = "scopeweave_promotion_target"
__table_args__ = (
UniqueConstraint(
"organization_id",
"workspace_id",
name="uq_scopeweave_promotion_target_scope",
),
)

id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
organization_id: Mapped[str | None] = mapped_column(
String, index=True, nullable=True
)
workspace_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
base_url: Mapped[str] = mapped_column(String, nullable=False)
access_token: Mapped[str] = mapped_column(EncryptedString, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
nullable=False,
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
onupdate=lambda: datetime.datetime.now(datetime.timezone.utc),
nullable=False,
)


class ScopeweavePromotionLink(Base):
"""Mapping of a naruon project-graph object to its scopeweave work item.

One row per promoted ``object_uid`` within a workspace; re-promoting updates
the same row so the citation-carrying link between naruon evidence and the
scopeweave work item stays stable and idempotent.
"""

__tablename__ = "scopeweave_promotion_link"
__table_args__ = (
UniqueConstraint(
"workspace_id",
"object_uid",
name="uq_scopeweave_promotion_link_object",
),
Index(
"ix_scopeweave_promotion_link_scope",
"organization_id",
"workspace_id",
"project_uid",
),
)

id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
organization_id: Mapped[str | None] = mapped_column(
String, index=True, nullable=True
)
workspace_id: Mapped[str] = mapped_column(String, index=True, nullable=False)
project_uid: Mapped[str] = mapped_column(String, index=True, nullable=False)
object_uid: Mapped[str] = mapped_column(String, index=True, nullable=False)
object_type: Mapped[str] = mapped_column(String, nullable=False)
scopeweave_work_item_id: Mapped[str] = mapped_column(String, nullable=False)
scopeweave_work_item_url: Mapped[str | None] = mapped_column(
String, nullable=True
)
promoted_confidence: Mapped[float] = mapped_column(Float, nullable=False)
citation_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
promoted_by_user_id: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
nullable=False,
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
onupdate=lambda: datetime.datetime.now(datetime.timezone.utc),
nullable=False,
)


class WorkspaceRunnerConfig(Base):
__tablename__ = "workspace_runner_configs"

Expand Down
28 changes: 28 additions & 0 deletions backend/services/llm_provider_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,3 +452,31 @@ async def build_llm_provider_http_client(
transport=_PinnedLLMProviderAsyncTransport(validated),
),
)


def build_pinned_https_async_client(
normalized_url: str,
hostname: str,
port: int,
addresses: tuple[str, ...],
) -> httpx.AsyncClient:
"""Build a DNS-pinned ``httpx.AsyncClient`` for an already-validated host.

The caller is responsible for validating the URL against its own host
allowlist (see ``core.url_validation.validate_https_url_host_details``)
before calling this helper. The returned client re-pins every outbound
connection to ``addresses`` and rejects any host/port that differs from the
validated one, closing the DNS-rebinding gap for outbound integrations that
reuse this hardened transport.
"""
pinned = ValidatedLLMProviderBaseURL(
normalized_url=normalized_url,
hostname=hostname,
port=port,
addresses=addresses,
)
return httpx.AsyncClient(
follow_redirects=False,
trust_env=False,
transport=_PinnedLLMProviderAsyncTransport(pinned),
)
Loading
Loading