mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: extend agent chat MCP tools for remote UAT evidence loops (#28233)
Extends the Agent-chat MCP tools so an unattended UAT evidence loop can
fetch artifacts, monitor long runs, and find prior runs without burning
model context.
## Backend
- New `chat_files_token` crypto key feature (migration 000571) with
rotator support and a dedicated signing keycache on coderd.
- `POST /api/experimental/chats/files/{file}/download-url`
(authenticated) mints a short-lived (5 min) signed URL and returns it
with `sha256`, `size_bytes`, `name`, `mime_type`, and `expires_at`.
- `GET /api/experimental/chats/files/{file}/download?token=` (no session
token) redeems the signed URL: verifies the JWS, requires the token's
`file_id` to match the path, and re-checks the minting user's RBAC
access live at redemption. Clients can `curl -o` artifacts with zero
credentials in the URL consumer.
- `ChatFileMetadata` gains `size_bytes` (via `octet_length`, no bytes
fetched).
## MCP tools (`codersdk/toolsdk`)
- `coder_download_chat_file`: by `file_id` or `chat_id`+`file_name`;
returns the signed URL plus checksum and size instead of base64.
- `coder_await_chat`: blocks (bounded `wait_secs`, 1-120) until a chat
leaves `running`/`interrupting`, using the existing watch stream with
subscribe-before-read.
- `coder_list_chats`: label, query, and limit filtering; chat
projections now include labels.
- `coder_get_chat_messages`: `after_id` forward cursor with
`next_after_id` (exact incremental reads), plus per-message `files`
metadata so artifact-bearing messages are identifiable.
- `coder_get_chat`: file listings now include `size_bytes` and
`created_at`.
- `coder_list_templates`: exposes `agents_allowed` for pre-flight
checks.
## Testing
- coderd: mint/redeem happy path with an unauthenticated client,
expired/tampered/file-mismatched tokens, auth still required on the
plain file endpoint, non-owner mint rejection.
- toolsdk: harness + integration coverage for all new/changed tools,
including signed-URL redemption with checksum verification,
forward-cursor exactness, await transition/timeout paths, and label
filtering.
- Remote dogfood UAT (dev.coder.com Coder Agent) passed all six
acceptance scenarios end to end over both MCP transports.
Note: `go test ./codersdk/toolsdk/` has a pre-existing goleak flake on
main (leaked `agentssh` non-PTY session goroutines from SSH exec tests;
reproduced 3/3 on clean `b4971bc49f1`). It is unrelated to this diff.
> Mux acted on Mike's behalf to create this PR.
<!-- mux-attribution: model=claude-sonnet-4-6 thinking=high -->
This commit is contained in:
Generated
+115
@@ -374,6 +374,91 @@ const docTemplate = `{
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/files/{file}/download": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
"application/pdf"
|
||||
],
|
||||
"tags": [
|
||||
"Chats"
|
||||
],
|
||||
"summary": "Download chat file with signed token",
|
||||
"operationId": "download-chat-file",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "File ID",
|
||||
"name": "file",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Signed download token",
|
||||
"name": "token",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/files/{file}/download-url": {
|
||||
"post": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Chats"
|
||||
],
|
||||
"summary": "Create chat file download URL",
|
||||
"operationId": "create-chat-file-download-url",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "File ID",
|
||||
"name": "file",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ChatFileDownloadURLResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/models": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
@@ -17906,6 +17991,31 @@ const docTemplate = `{
|
||||
"ChatErrorKindHookDenied"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileDownloadURLResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expires_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17930,6 +18040,9 @@ const docTemplate = `{
|
||||
"owner_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -19858,6 +19971,7 @@ const docTemplate = `{
|
||||
"workspace_apps_api_key",
|
||||
"workspace_apps_token",
|
||||
"oidc_convert",
|
||||
"chat_files_token",
|
||||
"tailnet_resume",
|
||||
"nats_ca"
|
||||
],
|
||||
@@ -19865,6 +19979,7 @@ const docTemplate = `{
|
||||
"CryptoKeyFeatureWorkspaceAppsAPIKey",
|
||||
"CryptoKeyFeatureWorkspaceAppsToken",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureChatFilesToken",
|
||||
"CryptoKeyFeatureTailnetResume",
|
||||
"CryptoKeyFeatureNATSCA"
|
||||
]
|
||||
|
||||
Generated
+109
@@ -327,6 +327,85 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/files/{file}/download": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
"application/pdf"
|
||||
],
|
||||
"tags": ["Chats"],
|
||||
"summary": "Download chat file with signed token",
|
||||
"operationId": "download-chat-file",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "File ID",
|
||||
"name": "file",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Signed download token",
|
||||
"name": "token",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/files/{file}/download-url": {
|
||||
"post": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Chats"],
|
||||
"summary": "Create chat file download URL",
|
||||
"operationId": "create-chat-file-download-url",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "File ID",
|
||||
"name": "file",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ChatFileDownloadURLResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/chats/models": {
|
||||
"get": {
|
||||
"description": "Experimental: this endpoint is subject to change.",
|
||||
@@ -16120,6 +16199,31 @@
|
||||
"ChatErrorKindHookDenied"
|
||||
]
|
||||
},
|
||||
"codersdk.ChatFileDownloadURLResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expires_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"mime_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ChatFileMetadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16144,6 +16248,9 @@
|
||||
"owner_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -17987,6 +18094,7 @@
|
||||
"workspace_apps_api_key",
|
||||
"workspace_apps_token",
|
||||
"oidc_convert",
|
||||
"chat_files_token",
|
||||
"tailnet_resume",
|
||||
"nats_ca"
|
||||
],
|
||||
@@ -17994,6 +18102,7 @@
|
||||
"CryptoKeyFeatureWorkspaceAppsAPIKey",
|
||||
"CryptoKeyFeatureWorkspaceAppsToken",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureChatFilesToken",
|
||||
"CryptoKeyFeatureTailnetResume",
|
||||
"CryptoKeyFeatureNATSCA"
|
||||
]
|
||||
|
||||
@@ -325,6 +325,7 @@ type Options struct {
|
||||
AppSigningKeyCache cryptokeys.SigningKeycache
|
||||
AppEncryptionKeyCache cryptokeys.EncryptionKeycache
|
||||
OIDCConvertKeyCache cryptokeys.SigningKeycache
|
||||
ChatFileTokenKeyCache cryptokeys.SigningKeycache
|
||||
// NATSCACache serves the NATS cluster mTLS CA via the generic signing key
|
||||
// cache for the nats_ca feature. SigningKey returns the active CA
|
||||
// (a *NATSCA); VerifyingKey returns a specific CA by sequence. The key
|
||||
@@ -595,6 +596,17 @@ func New(options *Options) *API {
|
||||
}
|
||||
}
|
||||
|
||||
if options.ChatFileTokenKeyCache == nil {
|
||||
options.ChatFileTokenKeyCache, err = cryptokeys.NewSigningCache(ctx,
|
||||
options.Logger.Named("chat_file_token_keycache"),
|
||||
fetcher,
|
||||
codersdk.CryptoKeyFeatureChatFilesToken,
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Fatal(ctx, "failed to properly instantiate chat file token signing cache", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if options.AppSigningKeyCache == nil {
|
||||
options.AppSigningKeyCache, err = cryptokeys.NewSigningCache(ctx,
|
||||
options.Logger.Named("app_signing_keycache"),
|
||||
@@ -1362,6 +1374,10 @@ func New(options *Options) *API {
|
||||
r.Delete("/", api.deleteUserAIProviderKey)
|
||||
})
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute))
|
||||
r.Get("/chats/files/{file}/download", api.downloadChatFile)
|
||||
})
|
||||
r.Route("/chats", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
@@ -1374,6 +1390,7 @@ func New(options *Options) *API {
|
||||
r.Route("/files", func(r chi.Router) {
|
||||
r.Use(httpmw.RateLimit(options.FilesRateLimit, time.Minute))
|
||||
r.Post("/", api.postChatFile)
|
||||
r.Post("/{file}/download-url", api.postChatFileDownloadURL)
|
||||
r.Get("/{file}", api.chatFileByID)
|
||||
})
|
||||
r.Route("/config", func(r chi.Router) {
|
||||
@@ -2496,6 +2513,7 @@ func (api *API) Close() error {
|
||||
}
|
||||
_ = api.NetworkTelemetryBatcher.Close()
|
||||
_ = api.OIDCConvertKeyCache.Close()
|
||||
_ = api.ChatFileTokenKeyCache.Close()
|
||||
_ = api.AppSigningKeyCache.Close()
|
||||
_ = api.AppEncryptionKeyCache.Close()
|
||||
if api.NATSCACache != nil {
|
||||
|
||||
@@ -204,6 +204,7 @@ type Options struct {
|
||||
NotificationsEnqueuer notifications.Enqueuer
|
||||
APIKeyEncryptionCache cryptokeys.EncryptionKeycache
|
||||
OIDCConvertKeyCache cryptokeys.SigningKeycache
|
||||
ChatFileTokenKeyCache cryptokeys.SigningKeycache
|
||||
Clock quartz.Clock
|
||||
Acquirer *provisionerdserver.Acquirer
|
||||
TelemetryReporter telemetry.Reporter
|
||||
@@ -693,6 +694,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
|
||||
Acquirer: options.Acquirer,
|
||||
AppEncryptionKeyCache: options.APIKeyEncryptionCache,
|
||||
OIDCConvertKeyCache: options.OIDCConvertKeyCache,
|
||||
ChatFileTokenKeyCache: options.ChatFileTokenKeyCache,
|
||||
ProvisionerdServerMetrics: options.ProvisionerdServerMetrics,
|
||||
WorkspaceBuilderMetrics: options.WorkspaceBuilderMetrics,
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ func isEncryptionKeyFeature(feature codersdk.CryptoKeyFeature) bool {
|
||||
|
||||
func isSigningKeyFeature(feature codersdk.CryptoKeyFeature) bool {
|
||||
switch feature {
|
||||
case codersdk.CryptoKeyFeatureTailnetResume, codersdk.CryptoKeyFeatureOIDCConvert, codersdk.CryptoKeyFeatureWorkspaceAppsToken, codersdk.CryptoKeyFeatureNATSCA:
|
||||
case codersdk.CryptoKeyFeatureTailnetResume, codersdk.CryptoKeyFeatureOIDCConvert, codersdk.CryptoKeyFeatureChatFilesToken, codersdk.CryptoKeyFeatureWorkspaceAppsToken, codersdk.CryptoKeyFeatureNATSCA:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
const (
|
||||
WorkspaceAppsTokenDuration = time.Minute
|
||||
OIDCConvertTokenDuration = time.Minute * 5
|
||||
// ChatFilesTokenDuration is also the lifetime of minted chat file
|
||||
// download URLs, keeping key retention aligned with token expiry.
|
||||
ChatFilesTokenDuration = time.Minute * 5
|
||||
TailnetResumeTokenDuration = time.Hour * 24
|
||||
// NATSCAOverlap is how long a NATS cluster CA certificate stays valid past
|
||||
// the end of its active-signing window (startsAt + keyDuration). The next CA
|
||||
@@ -46,6 +49,7 @@ var defaultRotatedFeatures = []database.CryptoKeyFeature{
|
||||
database.CryptoKeyFeatureWorkspaceAppsToken,
|
||||
database.CryptoKeyFeatureWorkspaceAppsAPIKey,
|
||||
database.CryptoKeyFeatureOIDCConvert,
|
||||
database.CryptoKeyFeatureChatFilesToken,
|
||||
database.CryptoKeyFeatureTailnetResume,
|
||||
}
|
||||
|
||||
@@ -273,6 +277,8 @@ func generateNewSecret(feature database.CryptoKeyFeature, startsAt time.Time, ke
|
||||
return generateKey(64)
|
||||
case database.CryptoKeyFeatureOIDCConvert:
|
||||
return generateKey(64)
|
||||
case database.CryptoKeyFeatureChatFilesToken:
|
||||
return generateKey(64)
|
||||
case database.CryptoKeyFeatureTailnetResume:
|
||||
return generateKey(64)
|
||||
case database.CryptoKeyFeatureNATSCA:
|
||||
@@ -298,6 +304,8 @@ func tokenDuration(feature database.CryptoKeyFeature) time.Duration {
|
||||
return WorkspaceAppsTokenDuration
|
||||
case database.CryptoKeyFeatureOIDCConvert:
|
||||
return OIDCConvertTokenDuration
|
||||
case database.CryptoKeyFeatureChatFilesToken:
|
||||
return ChatFilesTokenDuration
|
||||
case database.CryptoKeyFeatureTailnetResume:
|
||||
return TailnetResumeTokenDuration
|
||||
case database.CryptoKeyFeatureNATSCA:
|
||||
|
||||
@@ -513,7 +513,7 @@ func Test_rotateKeys(t *testing.T) {
|
||||
|
||||
keys, err := db.GetCryptoKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 5)
|
||||
require.Len(t, keys, 6)
|
||||
|
||||
kbf := keysByFeature(keys, defaultRotatedFeatures)
|
||||
|
||||
@@ -525,13 +525,16 @@ func Test_rotateKeys(t *testing.T) {
|
||||
// caused a key to be inserted.
|
||||
require.Len(t, kbf[database.CryptoKeyFeatureTailnetResume], 1)
|
||||
require.Len(t, kbf[database.CryptoKeyFeatureWorkspaceAppsToken], 1)
|
||||
require.Len(t, kbf[database.CryptoKeyFeatureChatFilesToken], 1)
|
||||
|
||||
oidcKey := kbf[database.CryptoKeyFeatureOIDCConvert][0]
|
||||
tailnetKey := kbf[database.CryptoKeyFeatureTailnetResume][0]
|
||||
appTokenKey := kbf[database.CryptoKeyFeatureWorkspaceAppsToken][0]
|
||||
chatFileTokenKey := kbf[database.CryptoKeyFeatureChatFilesToken][0]
|
||||
requireKey(t, oidcKey, database.CryptoKeyFeatureOIDCConvert, now, nullTime, validKey.Sequence)
|
||||
requireKey(t, tailnetKey, database.CryptoKeyFeatureTailnetResume, now, nullTime, deletedKey.Sequence+1)
|
||||
requireKey(t, appTokenKey, database.CryptoKeyFeatureWorkspaceAppsToken, now, nullTime, 1)
|
||||
requireKey(t, chatFileTokenKey, database.CryptoKeyFeatureChatFilesToken, now, nullTime, 1)
|
||||
newKey := kbf[database.CryptoKeyFeatureWorkspaceAppsAPIKey][0]
|
||||
oldKey := kbf[database.CryptoKeyFeatureWorkspaceAppsAPIKey][1]
|
||||
if newKey.Sequence == rotatedKey.Sequence {
|
||||
@@ -705,6 +708,8 @@ func requireKey(t *testing.T, key database.CryptoKey, feature database.CryptoKey
|
||||
switch key.Feature {
|
||||
case database.CryptoKeyFeatureOIDCConvert:
|
||||
require.Len(t, secret, 64)
|
||||
case database.CryptoKeyFeatureChatFilesToken:
|
||||
require.Len(t, secret, 64)
|
||||
case database.CryptoKeyFeatureWorkspaceAppsToken:
|
||||
require.Len(t, secret, 64)
|
||||
case database.CryptoKeyFeatureWorkspaceAppsAPIKey:
|
||||
|
||||
@@ -1903,6 +1903,7 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
|
||||
OrganizationID: row.OrganizationID,
|
||||
Name: row.Name,
|
||||
MimeType: row.Mimetype,
|
||||
SizeBytes: row.SizeBytes,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -848,6 +848,7 @@ func TestChat_FileMetadataConversion(t *testing.T) {
|
||||
OrganizationID: orgID,
|
||||
Name: "screenshot.png",
|
||||
Mimetype: "image/png",
|
||||
SizeBytes: 1234,
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
@@ -861,6 +862,7 @@ func TestChat_FileMetadataConversion(t *testing.T) {
|
||||
require.Equal(t, orgID, f.OrganizationID, "OrganizationID must be mapped from DB row")
|
||||
require.Equal(t, "screenshot.png", f.Name)
|
||||
require.Equal(t, "image/png", f.MimeType)
|
||||
require.Equal(t, int64(1234), f.SizeBytes)
|
||||
require.Equal(t, now, f.CreatedAt)
|
||||
|
||||
// Verify JSON serialization uses snake_case for mime_type.
|
||||
|
||||
@@ -945,6 +945,7 @@ func (s *MethodTestSuite) TestChats() {
|
||||
ID: file.ID,
|
||||
Name: file.Name,
|
||||
Mimetype: file.Mimetype,
|
||||
SizeBytes: int64(len(file.Data)),
|
||||
CreatedAt: file.CreatedAt,
|
||||
OwnerID: file.OwnerID,
|
||||
OrganizationID: file.OrganizationID,
|
||||
|
||||
@@ -2229,6 +2229,8 @@ func newCryptoKeySecret(feature database.CryptoKeyFeature) (string, error) {
|
||||
return generateCryptoKey(64)
|
||||
case database.CryptoKeyFeatureTailnetResume:
|
||||
return generateCryptoKey(64)
|
||||
case database.CryptoKeyFeatureChatFilesToken:
|
||||
return generateCryptoKey(64)
|
||||
case database.CryptoKeyFeatureNATSCA:
|
||||
return generateCACryptoKeySecret()
|
||||
}
|
||||
|
||||
Generated
+2
-1
@@ -398,7 +398,8 @@ CREATE TYPE crypto_key_feature AS ENUM (
|
||||
'workspace_apps_api_key',
|
||||
'oidc_convert',
|
||||
'tailnet_resume',
|
||||
'nats_ca'
|
||||
'nats_ca',
|
||||
'chat_files_token'
|
||||
);
|
||||
|
||||
CREATE TYPE display_app AS ENUM (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- PostgreSQL does not support removing enum values safely.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE crypto_key_feature ADD VALUE IF NOT EXISTS 'chat_files_token';
|
||||
Generated
+4
-1
@@ -2043,6 +2043,7 @@ const (
|
||||
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
|
||||
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
|
||||
CryptoKeyFeatureNATSCA CryptoKeyFeature = "nats_ca"
|
||||
CryptoKeyFeatureChatFilesToken CryptoKeyFeature = "chat_files_token"
|
||||
)
|
||||
|
||||
func (e *CryptoKeyFeature) Scan(src interface{}) error {
|
||||
@@ -2086,7 +2087,8 @@ func (e CryptoKeyFeature) Valid() bool {
|
||||
CryptoKeyFeatureWorkspaceAppsAPIKey,
|
||||
CryptoKeyFeatureOIDCConvert,
|
||||
CryptoKeyFeatureTailnetResume,
|
||||
CryptoKeyFeatureNATSCA:
|
||||
CryptoKeyFeatureNATSCA,
|
||||
CryptoKeyFeatureChatFilesToken:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -2099,6 +2101,7 @@ func AllCryptoKeyFeatureValues() []CryptoKeyFeature {
|
||||
CryptoKeyFeatureOIDCConvert,
|
||||
CryptoKeyFeatureTailnetResume,
|
||||
CryptoKeyFeatureNATSCA,
|
||||
CryptoKeyFeatureChatFilesToken,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -5930,7 +5930,8 @@ func (q *sqlQuerier) GetChatFileDataPrefixesByIDs(ctx context.Context, arg GetCh
|
||||
}
|
||||
|
||||
const getChatFileMetadataByChatID = `-- name: GetChatFileMetadataByChatID :many
|
||||
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at
|
||||
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at,
|
||||
octet_length(cf.data)::bigint AS size_bytes
|
||||
FROM chat_files cf
|
||||
JOIN chat_file_links cfl ON cfl.file_id = cf.id
|
||||
WHERE cfl.chat_id = $1::uuid
|
||||
@@ -5944,6 +5945,7 @@ type GetChatFileMetadataByChatIDRow struct {
|
||||
Name string `db:"name" json:"name"`
|
||||
Mimetype string `db:"mimetype" json:"mimetype"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
|
||||
}
|
||||
|
||||
// GetChatFileMetadataByChatID returns lightweight file metadata for
|
||||
@@ -5965,6 +5967,7 @@ func (q *sqlQuerier) GetChatFileMetadataByChatID(ctx context.Context, chatID uui
|
||||
&i.Name,
|
||||
&i.Mimetype,
|
||||
&i.CreatedAt,
|
||||
&i.SizeBytes,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ WHERE id = ANY(@ids::uuid[]);
|
||||
-- GetChatFileMetadataByChatID returns lightweight file metadata for
|
||||
-- all files linked to a chat. The data column is excluded to avoid
|
||||
-- loading file content.
|
||||
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at
|
||||
SELECT cf.id, cf.owner_id, cf.organization_id, cf.name, cf.mimetype, cf.created_at,
|
||||
octet_length(cf.data)::bigint AS size_bytes
|
||||
FROM chat_files cf
|
||||
JOIN chat_file_links cfl ON cfl.file_id = cf.id
|
||||
WHERE cfl.chat_id = @chat_id::uuid
|
||||
|
||||
+153
-1
@@ -3,7 +3,9 @@ package coderd
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -19,6 +22,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"golang.org/x/xerrors"
|
||||
@@ -26,6 +30,7 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
@@ -36,6 +41,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
@@ -5944,6 +5950,145 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ChatFileDownloadClaims are the signed claims embedded in a chat file
|
||||
// download URL token.
|
||||
type ChatFileDownloadClaims struct {
|
||||
jwtutils.RegisteredClaims
|
||||
FileID uuid.UUID `json:"file_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (c ChatFileDownloadClaims) Validate(expected jwt.Expected) error {
|
||||
if c.FileID == uuid.Nil {
|
||||
return xerrors.New("file ID is required")
|
||||
}
|
||||
if c.UserID == uuid.Nil {
|
||||
return xerrors.New("user ID is required")
|
||||
}
|
||||
return c.RegisteredClaims.Validate(expected)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
// @Summary Create chat file download URL
|
||||
// @ID create-chat-file-download-url
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Chats
|
||||
// @Produce json
|
||||
// @Param file path string true "File ID" format(uuid)
|
||||
// @Success 200 {object} codersdk.ChatFileDownloadURLResponse
|
||||
// @Router /api/experimental/chats/files/{file}/download-url [post]
|
||||
// @x-apidocgen {"skip": true}
|
||||
// @Description Experimental: this endpoint is subject to change.
|
||||
func (api *API) postChatFileDownloadURL(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid file ID.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
chatFile, err := api.Database.GetChatFileByID(ctx, fileID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get chat file.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// Truncate to whole seconds so the advertised expiry matches the JWT
|
||||
// exp claim, which jwt.NewNumericDate stores at second precision.
|
||||
expiresAt := now.Add(cryptokeys.ChatFilesTokenDuration).Truncate(time.Second)
|
||||
claims := ChatFileDownloadClaims{
|
||||
RegisteredClaims: jwtutils.RegisteredClaims{
|
||||
Expiry: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
FileID: fileID,
|
||||
UserID: httpmw.APIKey(r).UserID,
|
||||
}
|
||||
token, err := jwtutils.Sign(ctx, api.ChatFileTokenKeyCache, claims)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to create chat file download URL.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
downloadURL := api.AccessURL.JoinPath("api", "experimental", "chats", "files", fileID.String(), "download")
|
||||
downloadURL.RawQuery = url.Values{"token": {token}}.Encode()
|
||||
digest := sha256.Sum256(chatFile.Data)
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatFileDownloadURLResponse{
|
||||
URL: downloadURL.String(),
|
||||
ExpiresAt: expiresAt,
|
||||
SHA256: hex.EncodeToString(digest[:]),
|
||||
SizeBytes: int64(len(chatFile.Data)),
|
||||
Name: chatFile.Name,
|
||||
MimeType: chatFile.Mimetype,
|
||||
})
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
// @Summary Download chat file with signed token
|
||||
// @ID download-chat-file
|
||||
// @Tags Chats
|
||||
// @Produce image/png,image/jpeg,image/gif,image/webp,text/plain,text/markdown,text/csv,application/json,application/pdf
|
||||
// @Param file path string true "File ID" format(uuid)
|
||||
// @Param token query string true "Signed download token"
|
||||
// @Success 200
|
||||
// @Router /api/experimental/chats/files/{file}/download [get]
|
||||
// @x-apidocgen {"skip": true}
|
||||
// @Description Experimental: this endpoint is subject to change.
|
||||
func (api *API) downloadChatFile(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
fileID, err := uuid.Parse(chi.URLParam(r, "file"))
|
||||
if err != nil {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
var claims ChatFileDownloadClaims
|
||||
if err := jwtutils.Verify(ctx, api.ChatFileTokenKeyCache, r.URL.Query().Get("token"), &claims); err != nil || claims.FileID != fileID {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
subject, status, err := httpmw.UserRBACSubject(ctx, api.Database, claims.UserID, rbac.ScopeAll)
|
||||
if err != nil || status != database.UserStatusActive {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
chatFile, err := api.Database.GetChatFileByID(dbauthz.As(ctx, subject), fileID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
// This endpoint is reachable without a session token, so internal
|
||||
// error details stay in the logs rather than the response body.
|
||||
api.Logger.Error(ctx, "failed to get chat file for signed download", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get chat file.",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Never let private caches replay a signed URL past its expiry;
|
||||
// revocation is re-checked only when the request reaches coderd.
|
||||
rw.Header().Set("Cache-Control", "no-store")
|
||||
api.serveChatFile(ctx, rw, chatFile)
|
||||
}
|
||||
|
||||
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
|
||||
//
|
||||
// @Summary Get chat file
|
||||
@@ -5980,6 +6125,14 @@ func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
api.serveChatFile(r.Context(), rw, chatFile)
|
||||
}
|
||||
|
||||
// serveChatFile writes the file body and content headers; callers set
|
||||
// Cache-Control because signed and session-authenticated downloads have
|
||||
// different caching requirements.
|
||||
func (api *API) serveChatFile(ctx context.Context, rw http.ResponseWriter, chatFile database.ChatFile) {
|
||||
rw.Header().Set("Content-Type", chatFile.Mimetype)
|
||||
disposition := "attachment"
|
||||
if chatfiles.IsInlineRenderableStoredMediaType(chatFile.Mimetype) {
|
||||
@@ -5990,7 +6143,6 @@ func (api *API) chatFileByID(rw http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
rw.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
rw.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
rw.Header().Set("Content-Length", strconv.Itoa(len(chatFile.Data)))
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
if _, err := rw.Write(chatFile.Data); err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package coderd_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
@@ -41,6 +44,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
@@ -8071,6 +8075,7 @@ func TestChatMessageWithFiles(t *testing.T) {
|
||||
require.Equal(t, firstUser.UserID, f.OwnerID)
|
||||
require.NotEqual(t, uuid.Nil, f.OrganizationID)
|
||||
require.Equal(t, "image/png", f.MimeType)
|
||||
require.Equal(t, int64(len(pngData)), f.SizeBytes)
|
||||
require.Equal(t, "test.png", f.Name)
|
||||
require.NotZero(t, f.CreatedAt)
|
||||
})
|
||||
@@ -11272,6 +11277,214 @@ func TestGetChatFile(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatFileDownloadURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newClient := func(t *testing.T) (*codersdk.ExperimentalClient, jwtutils.StaticKey) {
|
||||
t.Helper()
|
||||
key := jwtutils.StaticKey{ID: "1", Key: []byte(strings.Repeat("k", 64))}
|
||||
client := newChatClient(t, func(options *coderdtest.Options) {
|
||||
options.ChatFileTokenKeyCache = key
|
||||
})
|
||||
return client, key
|
||||
}
|
||||
uploadPNG := func(t *testing.T, ctx context.Context, client *codersdk.ExperimentalClient, organizationID uuid.UUID, name string) (codersdk.UploadChatFileResponse, []byte) {
|
||||
t.Helper()
|
||||
data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...)
|
||||
uploaded, err := client.UploadChatFile(ctx, organizationID, "image/png", name, bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
return uploaded, data
|
||||
}
|
||||
get := func(t *testing.T, ctx context.Context, rawURL string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return res
|
||||
}
|
||||
|
||||
t.Run("MintAndRedeem", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
uploaded, data := uploadPNG(t, ctx, client, firstUser.OrganizationID, "evidence.png")
|
||||
|
||||
download, err := client.ChatFileDownloadURL(ctx, uploaded.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(len(data)), download.SizeBytes)
|
||||
require.Equal(t, fmt.Sprintf("%x", sha256.Sum256(data)), download.SHA256)
|
||||
require.Equal(t, "evidence.png", download.Name)
|
||||
require.Equal(t, "image/png", download.MimeType)
|
||||
require.True(t, download.ExpiresAt.After(time.Now()))
|
||||
// expires_at must match the JWT exp claim, which is stored at
|
||||
// second precision; sub-second drift would overstate validity.
|
||||
require.True(t, download.ExpiresAt.Equal(download.ExpiresAt.Truncate(time.Second)))
|
||||
|
||||
res := get(t, ctx, download.URL)
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
require.Equal(t, "image/png", res.Header.Get("Content-Type"))
|
||||
require.Equal(t, "no-store", res.Header.Get("Cache-Control"))
|
||||
got, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, data, got)
|
||||
})
|
||||
|
||||
t.Run("ExpiredToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, key := newClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "expired.png")
|
||||
token, err := jwtutils.Sign(ctx, key, coderd.ChatFileDownloadClaims{
|
||||
RegisteredClaims: jwtutils.RegisteredClaims{
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Minute)),
|
||||
},
|
||||
FileID: uploaded.ID,
|
||||
UserID: firstUser.UserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadURL := client.URL.JoinPath("api", "experimental", "chats", "files", uploaded.ID.String(), "download")
|
||||
query := downloadURL.Query()
|
||||
query.Set("token", token)
|
||||
downloadURL.RawQuery = query.Encode()
|
||||
|
||||
res := get(t, ctx, downloadURL.String())
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("TamperedToken", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "tampered.png")
|
||||
download, err := client.ChatFileDownloadURL(ctx, uploaded.ID)
|
||||
require.NoError(t, err)
|
||||
downloadURL, err := url.Parse(download.URL)
|
||||
require.NoError(t, err)
|
||||
token := downloadURL.Query().Get("token")
|
||||
require.NotEmpty(t, token)
|
||||
first := byte('A')
|
||||
if token[0] == first {
|
||||
first = 'B'
|
||||
}
|
||||
query := downloadURL.Query()
|
||||
query.Set("token", string(first)+token[1:])
|
||||
downloadURL.RawQuery = query.Encode()
|
||||
|
||||
res := get(t, ctx, downloadURL.String())
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("TokenFileMismatch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
fileA, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "a.png")
|
||||
fileB, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "b.png")
|
||||
download, err := client.ChatFileDownloadURL(ctx, fileA.ID)
|
||||
require.NoError(t, err)
|
||||
downloadURL, err := url.Parse(download.URL)
|
||||
require.NoError(t, err)
|
||||
downloadURL.Path = fmt.Sprintf("/api/experimental/chats/files/%s/download", fileB.ID)
|
||||
|
||||
res := get(t, ctx, downloadURL.String())
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("PlainGetRequiresAuthentication", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newClient(t)
|
||||
anonymous := codersdk.NewExperimentalClient(codersdk.New(client.URL))
|
||||
|
||||
_, _, err := anonymous.GetChatFile(ctx, uuid.New())
|
||||
requireSDKError(t, err, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
t.Run("NonOwnerCannotMint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "owner.png")
|
||||
otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
|
||||
otherClient := codersdk.NewExperimentalClient(otherClientRaw)
|
||||
|
||||
_, err := otherClient.ChatFileDownloadURL(ctx, uploaded.ID)
|
||||
requireSDKError(t, err, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("RevokedShareCannotRedeem", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
_ = createChatModelConfig(t, client)
|
||||
uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "shared.png")
|
||||
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Content: []codersdk.ChatInputPart{
|
||||
{Type: codersdk.ChatInputPartTypeText, Text: "shared evidence"},
|
||||
{Type: codersdk.ChatInputPartTypeFile, FileID: uploaded.ID},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
|
||||
memberClient := codersdk.NewExperimentalClient(memberRaw)
|
||||
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
|
||||
UserRoles: map[string]codersdk.ChatRole{member.ID.String(): codersdk.ChatRoleRead},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The member can mint while the share is active.
|
||||
download, err := memberClient.ChatFileDownloadURL(ctx, uploaded.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Revoking the share invalidates the already-minted URL because
|
||||
// redemption rechecks the minting user's access live.
|
||||
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
|
||||
UserRoles: map[string]codersdk.ChatRole{member.ID.String(): codersdk.ChatRoleDeleted},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
res := get(t, ctx, download.URL)
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("SuspendedUserCannotRedeem", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
client, _ := newClient(t)
|
||||
firstUser := coderdtest.CreateFirstUser(t, client.Client)
|
||||
memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
|
||||
memberClient := codersdk.NewExperimentalClient(memberRaw)
|
||||
uploaded, _ := uploadPNG(t, ctx, memberClient, firstUser.OrganizationID, "suspended.png")
|
||||
|
||||
download, err := memberClient.ChatFileDownloadURL(ctx, uploaded.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Suspending the minting user invalidates the URL because
|
||||
// redemption requires the token's user to still be active.
|
||||
_, err = client.Client.UpdateUserStatus(ctx, member.ID.String(), codersdk.UserStatusSuspended)
|
||||
require.NoError(t, err)
|
||||
|
||||
res := get(t, ctx, download.URL)
|
||||
defer res.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// seedChatGatewayRequest records one finished Coder Agents gateway request
|
||||
// under sessionChatID, mirroring aibridged: the session ID is the spawning
|
||||
// chat, and each usage is one provider response within that one request.
|
||||
|
||||
@@ -42,8 +42,11 @@ func Middleware(tracerProvider trace.TracerProvider) func(http.Handler) http.Han
|
||||
}
|
||||
|
||||
// Start span with default span name. Span name will be updated to
|
||||
// "method route" format once request finishes.
|
||||
r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.RequestURI))
|
||||
// "method route" format once request finishes. The initial name
|
||||
// excludes the query string because span names are exported to
|
||||
// tracing backends at span start and some endpoints accept
|
||||
// bearer credentials as query parameters.
|
||||
r, span := StartHTTPSpan(tracer, rw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path))
|
||||
defer span.End()
|
||||
|
||||
sw, ok := rw.(*StatusWriter)
|
||||
|
||||
@@ -5,11 +5,14 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
|
||||
@@ -43,6 +46,24 @@ func (f *fakeTracer) Start(ctx context.Context, _ string, _ ...trace.SpanStartOp
|
||||
return ctx, tracing.NoopSpan
|
||||
}
|
||||
|
||||
// startNameRecorder captures span names as they are at span start, before
|
||||
// EndHTTPSpan renames them, because span names are exported to tracing
|
||||
// backends at span start.
|
||||
type startNameRecorder struct {
|
||||
mu sync.Mutex
|
||||
names []string
|
||||
}
|
||||
|
||||
func (r *startNameRecorder) OnStart(_ context.Context, s sdktrace.ReadWriteSpan) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.names = append(r.names, s.Name())
|
||||
}
|
||||
|
||||
func (*startNameRecorder) OnEnd(sdktrace.ReadOnlySpan) {}
|
||||
func (*startNameRecorder) Shutdown(context.Context) error { return nil }
|
||||
func (*startNameRecorder) ForceFlush(context.Context) error { return nil }
|
||||
|
||||
func Test_Middleware(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -99,4 +120,47 @@ func Test_Middleware(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("QueryCredentialsNotExported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Some endpoints accept bearer credentials as query parameters
|
||||
// (e.g. signed chat file download tokens). No exported span name
|
||||
// or attribute may carry the query string.
|
||||
const token = "super-secret-download-token"
|
||||
|
||||
startNames := &startNameRecorder{}
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSpanProcessor(startNames),
|
||||
sdktrace.WithSpanProcessor(recorder),
|
||||
)
|
||||
|
||||
rw := &tracing.StatusWriter{ResponseWriter: httptest.NewRecorder()}
|
||||
r := httptest.NewRequest("GET", "/api/experimental/chats/files/abc/download?token="+token, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
tracing.Middleware(provider)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rw, r)
|
||||
|
||||
require.NoError(t, provider.ForceFlush(ctx))
|
||||
require.NotEmpty(t, startNames.names)
|
||||
for _, name := range startNames.names {
|
||||
require.NotContains(t, name, token)
|
||||
}
|
||||
spans := recorder.Ended()
|
||||
require.NotEmpty(t, spans)
|
||||
for _, span := range spans {
|
||||
require.NotContains(t, span.Name(), token)
|
||||
for _, attr := range span.Attributes() {
|
||||
require.NotContains(t, attr.Value.Emit(), token,
|
||||
"span attribute %s must not carry query credentials", attr.Key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user