Skip to content

FEAT: Security & Azure deployment for CoPyRIT GUI#1554

Open
adrian-gavrila wants to merge 161 commits intomicrosoft:mainfrom
adrian-gavrila:adrian-gavrila/frontend-attack-view
Open

FEAT: Security & Azure deployment for CoPyRIT GUI#1554
adrian-gavrila wants to merge 161 commits intomicrosoft:mainfrom
adrian-gavrila:adrian-gavrila/frontend-attack-view

Conversation

@adrian-gavrila
Copy link
Copy Markdown
Contributor

Description

Adds authentication, security middleware, Azure infrastructure-as-code, and a CI/CD pipeline for deploying the CoPyRIT GUI as an
Azure Container App.

Authentication

  • Frontend: MSAL PKCE auth via @azure/msal-browser — no client secrets needed. New AuthProvider and msalConfig components;
    Axios interceptor attaches Bearer tokens automatically.
  • Backend: FastAPI JWT middleware (pyrit/backend/middleware/auth.py) validates tokens against Entra ID JWKS. Supports multi-group
    authorization via allowedGroupObjectIds. Auth gracefully disables when env vars are unset (local dev).
  • Auth info endpoint (/api/auth/me) for the frontend to display the signed-in user.

Security middleware

  • SecurityHeadersMiddleware: CSP, HSTS (prod only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy,
    Cache-Control (no-store on API routes). Swagger disabled in production.

Infrastructure (Bicep)

  • infra/main.bicep + ARM template: provisions Container App Environment, ACR (optional), Log Analytics, Application Insights
    (opt-in OTel), User-Assigned Managed Identity, and VNet/Private Endpoint (opt-in).
  • Key Vault secret references for .env injection — no secrets baked into the container.
  • infra/README.md: full deployment guide covering prerequisites, Entra setup, RBAC grants, and post-deployment steps.

CI/CD

  • gui-deploy.yml: ADO pipeline — build → push to ACR → deploy to test → opt-in prod promotion.

Docker

  • Updated Dockerfile, start.sh, and docker-compose.yaml for managed identity auth on Azure and service principal auth locally.
  • .pyrit_conf_example with operator/operation label examples.

Tests and Documentation

  • frontend/src/App.test.tsx and frontend/src/services/api.test.ts updated to cover auth integration.
  • infra/README.md serves as the deployment guide with full prerequisites, step-by-step setup, post-deployment checklist, and
    teardown instructions.
  • docker/QUICKSTART.md updated with auth configuration and local dev workflow.
  • No Jupyter notebooks were added or modified; JupyText not applicable.

romanlutz and others added 30 commits February 28, 2026 14:49
- Add run_initializers_async to pyrit.setup for programmatic initialization
- Switch AIRTInitializer to Entra (Azure AD) auth, removing API key requirements
- Add --config-file flag to pyrit_backend CLI
- Use PyRIT configuration loader in FrontendCore and pyrit_backend
- Update AIRTTargetInitializer with new target types

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add conversation_stats model and attack_result extensions
- Add get_attack_results with filtering by harm categories, labels,
  attack type, and converter types to memory interface
- Implement SQLite-specific JSON filtering for attack results
- Add memory_models field for targeted_harm_categories
- Add prompt_metadata support to openai image/video/response targets
- Fix missing return statements in SQLite harm_category and label filters

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add attack CRUD routes with conversation management
- Add message sending with target dispatch and response handling
- Add attack mappers for domain-to-DTO conversion with signed blob URLs
- Add attack service with video remix support and piece persistence
- Expand target service and routes with registry-based target management
- Add version endpoint with database info

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add attack-centric chat UI with multi-conversation support
- Add conversation panel with branching and message actions
- Add attack history view with filtering
- Add labels bar for attack metadata
- Add target configuration with create dialog
- Add message mapper utilities for backend/frontend translation
- Add video playback support with signed blob URLs
- Add InputBox with attachment support and auto-expand
- Update dev.py with --detach, logs, and process management
- Add e2e tests for chat, config, and flow scenarios

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ssibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Rename supports_multiturn_chat to supports_multi_turn to align with TargetCapabilities field
- Use target_obj.capabilities.supports_multi_turn instead of isinstance check
- Update tests to set capabilities on mock targets

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…_async

Reverts the separate run_initializers_async function and restores the
original pattern where run_scenario_async calls initialize_pyrit_async
a second time with initializers. This avoids a larger refactor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Catch ValueError in get_conversation_messages route, return 400
- Fix target_registry_name field description
- Simplify redundant except (ValueError, Exception) to except Exception
- Fix docstring: converter_classes -> converter_types
- Fix test assertions: converter_types -> converter_classes (matches memory API)
- Remove dead tests for deleted helper methods
- Restore azure_openai_video target config to match main

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Move _inject_video_id_from_history and _strip_video_pieces methods from
  AttackService to OpenAIVideoTarget where they belong
- Update _validate_request to accept video_path pieces and check for
  video_path+image_path conflicts
- Add ValueError when video_path is present but no video_id can be resolved
- Add 7 unit tests for the inject/strip logic
- Remove video-specific logic from attack_service._send_and_store_message

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adrian Gavrila and others added 5 commits March 27, 2026 15:41
- Add Development Workflow section (local dev, auth options, promotion flow)
- Fix pipeline: allowedGroupObjectId -> allowedGroupObjectIds (matches ADO var)
- Quote comma-separated group IDs in bash to prevent shell splitting
- Document nested group limitation, app roles, AADSTS50105 troubleshooting
- Document appRoleAssignmentRequired as built-in hardening
- Update CSP description for blob storage allowance

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
….com

- Rewrite legacy graph.windows.net endpoint to graph.microsoft.com/v1.0
- Add @odata.nextLink pagination for large group lists
- Azure AD Graph was retired in 2023; this ensures overage resolution
  continues working for users in >200 groups

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace internal resource names, CIDRs, policy IDs with placeholders
- Remove SFI-SM, CFS0001, team-specific tag values
- Fix SQL output message to include -identity suffix
- Add KV troubleshooting, quick-start, environments table to README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@@ -0,0 +1,229 @@
# CI/CD pipeline for CoPyRIT GUI deployment.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should put all the ADO pipelines in a dedicated folder at some point...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adrian Gavrila and others added 2 commits March 31, 2026 13:31
@azure/msal-react@5.0.7 requires react@^19.2.1 but the project
uses react@^18.3.1, causing ERESOLVE failures in CI. Adding
legacy-peer-deps=true to .npmrc resolves the conflict.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests for msalConfig.ts (9 tests):
- getApiScopes: default vs client-specific scopes
- buildLoginRequest: wraps scopes for MSAL loginRedirect
- buildMsalConfig: assembles MSAL Configuration from AuthConfig
- fetchAuthConfig: success, caching, non-OK response, network error

Tests for AuthProvider.tsx (10 tests):
- Loading state, auth disabled (empty config), OR branch
- Error handling (Error vs non-Error)
- Full MSAL init, redirect account, cached account fallback
- LoginRedirect rendering and failure handling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
if "graph.windows.net" in endpoint:
# Legacy format: https://graph.windows.net/{tenant}/users/{oid}/getMemberObjects
# Graph format: https://graph.microsoft.com/v1.0/me/getMemberObjects
endpoint = "https://graph.microsoft.com/v1.0/me/getMemberObjects"
Copy link
Copy Markdown
Contributor

@behnam-o behnam-o Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we have a way to test if this path gets hit (i.e. too many groups -> call graph to get group memberships) to validate this logic is working as expected (group overage)

I believe in order to call this endpoint, we'd need a token issued for calling graph, (i.e scope must be something like https://graph.microsoft.com/<scope> but the token we send to this backed server is the one we fetch on the front end, and the audience for it is <copyrit-client-id>/access ... so calling graph with with throws a 401.

I think the reason we never see this in tests env is the group claim is always populated with the group that warranted our access to the app. (maybe this happened after we "require assignment" on the app? I remember at some point we did have the _claim_sources somewhere)

Copy link
Copy Markdown
Contributor

@behnam-o behnam-o Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking more about this, I'm wondering if we need to even have a token whose audience is the copyRIT app, if all we're doing is checking group memberships of a user?

and instead, simply get a graph token from the beginning (ie. in the front end, set "loginScope ="https://graph.microsoft.com/User.Read"

and not even expose the access scope on our app and try to get a token with that scope. that scope itself is not used after all. it looks like all we're using it for is, in conjunction with this fact:

Access tokens include the groups claim when the app manifest has groupMembershipClaims: "SecurityGroup" configured.

to get a token that has group memberships included in it

(this could be a follow-up optimization, and will probably make our token usable against graph.microsoft.com if we want to handle group overage)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
*/
export function getApiScopes(clientId: string): string[] {
if (!clientId) return ['openid', 'profile', 'email']
return [`${clientId}/access`]
Copy link
Copy Markdown
Contributor

@behnam-o behnam-o Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from our chat, I think this might be more optimal and cleaner, and also address my other comment:

  1. add back Microsoft Graph's User.Read delegated API permission to the app registration
  2. in getApiScopes in the frontend, request a token with this scope ["https://graph.microsoft.com/User.Read"]
  3. then group resolution will work okay (because the token that's sent to our backend will have graph as its audience)
  4. now you can remove access from "exposed APIs" of the app because it's not used anyways
  5. we can also remove the "App Roles" (Copyrit.Dev.All) , and instead assign those 2 groups to the ServicePrincipal (Enterprise Application) with the default role (we can do that even right now, because we don't check this role value in user claims)

cmd.extend(["-v", f"{pyrit_conf_file}:/home/vscode/.pyrit/.pyrit_conf:ro"])

# Mount Azure CLI config so 'az login' tokens persist across container restarts
azure_dir = Path.home() / ".azure"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where / how does this azure directory get created ?

expect(screen.getByText("Authentication Error")).toBeInTheDocument();
expect(
screen.getByText("Failed to initialize authentication")
).toBeInTheDocument();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should be using toBeVisible https://github.com/testing-library/jest-dom#tobevisible or get the text in the box directly and use toEqual. toBeInTheDocument could mean that it's not actually visible

);
});

consoleSpy.mockRestore();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may just want to restore all mocks after each test bc they really shouldn't be dependent on each other

}
}

if (!cancelled) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this cancelled variable seems weird to me; I'm wondering if we could have it as a state variable and then add it to the dependency list ?

let _cachedConfig: AuthConfig | null = null

export async function fetchAuthConfig(): Promise<AuthConfig> {
if (_cachedConfig) return _cachedConfig
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a better way to do this ? we're basically using a global variable here. Also isn't the return value not a promise ?

}

function App() {
const { instance } = useMsal()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: {msalInstance}

"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"scheduler": "^0.27.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this one for?

"devDependencies": {
"@eslint/js": "^9.15.0",
"@playwright/test": "^1.40.0",
"@testing-library/dom": "^10.4.1",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this one

> ⚠️ Auth-disabled mode is for **local development only**. Never deploy to a
> network-accessible environment without both env vars set.

### Promotion flow
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: promotion seems like weird wording to me maybe just workflow ?

Comment on lines +60 to +62
[PKCE](https://oauth.net/2/pkce/) on the frontend (`@azure/msal-browser`) +
FastAPI JWT middleware on the backend. Validates Bearer tokens against Entra ID
JWKS. PKCE (public client) — no client secrets or certificates needed.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so ik they use the acronyms in the docs but can we specify what they stand for at least once (i mightve missed it if it was farther up). It's kinda hard to read as is (at least for a noob like me 😁)

be created manually — see [Post-Deployment §2](#post-deployment).

**Requirements:**
- Azure CLI **2.84+** (version 2.77 has a known `content-already-consumed` bug)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be good to have a link to the download


**Requirements:**
- Azure CLI **2.84+** (version 2.77 has a known `content-already-consumed` bug)
- Container image must be pushed to ACR **before** deployment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you have more info on what this means / how to do it ?

content={"detail": "Missing or invalid Authorization header"},
)

token = auth_header[7:] # Strip "Bearer "
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where does this 7 come from ?

Comment on lines +93 to +127
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=401,
content={"detail": "Missing or invalid Authorization header"},
)

token = auth_header[7:] # Strip "Bearer "

# Validate JWT
user, claims = self._validate_token(token)
if user is None:
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired token"},
)

# Handle groups overage: when user is in >200 groups, Entra replaces the
# groups array with _claim_sources containing a Graph API URL.
if not user.groups and self._allowed_group_ids and "_claim_sources" in claims:
user.groups = await self._resolve_groups_overage_async(claims, token)

# Authorization: check group membership
if not self._is_authorized(user):
logger.warning(
"User %s (%s) denied — groups=%s, allowed_groups=%s",
user.email,
user.oid,
user.groups,
self._allowed_group_ids,
)
return JSONResponse(
status_code=403,
content={"detail": "You are not authorized to access this application"},
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put all this auth logic in a function


async def _resolve_groups_overage_async(self, claims: dict[str, Any], token: str) -> list[str]:
"""
Resolve group membership via Microsoft Graph when groups overage occurs.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Resolve group membership via Microsoft Graph when groups overage occurs.
Resolve group membership via Microsoft Graph when groups exceed 200.

endpoint = src.get("endpoint", "")

if not endpoint:
logger.debug("No overage endpoint found in _claim_sources")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk if this is common language, but overage is not intuitive what we're talking about. I think it should be _resolve_group_membership_exceeded_async or something like that and then corresponding function naming

Comment on lines +28 to +30
AZURE_TENANT_ID=<your-tenant-id>
AZURE_CLIENT_ID=<your-client-id>
AZURE_CLIENT_SECRET=<your-client-secret>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when are we using entra vs azure ? bc we're using both in this PR

"openpyxl>=3.1.5",
"pillow>=12.1.1",
"pydantic>=2.11.5",
"PyJWT[crypto]>=2.8.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if we should split these into frontend dependencies (not something that needs to happen in this PR but just something I was considering)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants