s3_presigned_url_iam_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. package s3api
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "time"
  8. "github.com/golang-jwt/jwt/v5"
  9. "github.com/seaweedfs/seaweedfs/weed/iam/integration"
  10. "github.com/seaweedfs/seaweedfs/weed/iam/ldap"
  11. "github.com/seaweedfs/seaweedfs/weed/iam/oidc"
  12. "github.com/seaweedfs/seaweedfs/weed/iam/policy"
  13. "github.com/seaweedfs/seaweedfs/weed/iam/sts"
  14. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
  15. "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
  16. "github.com/stretchr/testify/assert"
  17. "github.com/stretchr/testify/require"
  18. )
  19. // createTestJWTPresigned creates a test JWT token with the specified issuer, subject and signing key
  20. func createTestJWTPresigned(t *testing.T, issuer, subject, signingKey string) string {
  21. token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
  22. "iss": issuer,
  23. "sub": subject,
  24. "aud": "test-client-id",
  25. "exp": time.Now().Add(time.Hour).Unix(),
  26. "iat": time.Now().Unix(),
  27. // Add claims that trust policy validation expects
  28. "idp": "test-oidc", // Identity provider claim for trust policy matching
  29. })
  30. tokenString, err := token.SignedString([]byte(signingKey))
  31. require.NoError(t, err)
  32. return tokenString
  33. }
  34. // TestPresignedURLIAMValidation tests IAM validation for presigned URLs
  35. func TestPresignedURLIAMValidation(t *testing.T) {
  36. // Set up IAM system
  37. iamManager := setupTestIAMManagerForPresigned(t)
  38. s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
  39. // Create IAM with integration
  40. iam := &IdentityAccessManagement{
  41. isAuthEnabled: true,
  42. }
  43. iam.SetIAMIntegration(s3iam)
  44. // Set up roles
  45. ctx := context.Background()
  46. setupTestRolesForPresigned(ctx, iamManager)
  47. // Create a valid JWT token for testing
  48. validJWTToken := createTestJWTPresigned(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
  49. // Get session token
  50. response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
  51. RoleArn: "arn:seaweed:iam::role/S3ReadOnlyRole",
  52. WebIdentityToken: validJWTToken,
  53. RoleSessionName: "presigned-test-session",
  54. })
  55. require.NoError(t, err)
  56. sessionToken := response.Credentials.SessionToken
  57. tests := []struct {
  58. name string
  59. method string
  60. path string
  61. sessionToken string
  62. expectedResult s3err.ErrorCode
  63. }{
  64. {
  65. name: "GET object with read permissions",
  66. method: "GET",
  67. path: "/test-bucket/test-file.txt",
  68. sessionToken: sessionToken,
  69. expectedResult: s3err.ErrNone,
  70. },
  71. {
  72. name: "PUT object with read-only permissions (should fail)",
  73. method: "PUT",
  74. path: "/test-bucket/new-file.txt",
  75. sessionToken: sessionToken,
  76. expectedResult: s3err.ErrAccessDenied,
  77. },
  78. {
  79. name: "GET object without session token",
  80. method: "GET",
  81. path: "/test-bucket/test-file.txt",
  82. sessionToken: "",
  83. expectedResult: s3err.ErrNone, // Falls back to standard auth
  84. },
  85. {
  86. name: "Invalid session token",
  87. method: "GET",
  88. path: "/test-bucket/test-file.txt",
  89. sessionToken: "invalid-token",
  90. expectedResult: s3err.ErrAccessDenied,
  91. },
  92. }
  93. for _, tt := range tests {
  94. t.Run(tt.name, func(t *testing.T) {
  95. // Create request with presigned URL parameters
  96. req := createPresignedURLRequest(t, tt.method, tt.path, tt.sessionToken)
  97. // Create identity for testing
  98. identity := &Identity{
  99. Name: "test-user",
  100. Account: &AccountAdmin,
  101. }
  102. // Test validation
  103. result := iam.ValidatePresignedURLWithIAM(req, identity)
  104. assert.Equal(t, tt.expectedResult, result, "IAM validation result should match expected")
  105. })
  106. }
  107. }
  108. // TestPresignedURLGeneration tests IAM-aware presigned URL generation
  109. func TestPresignedURLGeneration(t *testing.T) {
  110. // Set up IAM system
  111. iamManager := setupTestIAMManagerForPresigned(t)
  112. s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
  113. s3iam.enabled = true // Enable IAM integration
  114. presignedManager := NewS3PresignedURLManager(s3iam)
  115. ctx := context.Background()
  116. setupTestRolesForPresigned(ctx, iamManager)
  117. // Create a valid JWT token for testing
  118. validJWTToken := createTestJWTPresigned(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
  119. // Get session token
  120. response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
  121. RoleArn: "arn:seaweed:iam::role/S3AdminRole",
  122. WebIdentityToken: validJWTToken,
  123. RoleSessionName: "presigned-gen-test-session",
  124. })
  125. require.NoError(t, err)
  126. sessionToken := response.Credentials.SessionToken
  127. tests := []struct {
  128. name string
  129. request *PresignedURLRequest
  130. shouldSucceed bool
  131. expectedError string
  132. }{
  133. {
  134. name: "Generate valid presigned GET URL",
  135. request: &PresignedURLRequest{
  136. Method: "GET",
  137. Bucket: "test-bucket",
  138. ObjectKey: "test-file.txt",
  139. Expiration: time.Hour,
  140. SessionToken: sessionToken,
  141. },
  142. shouldSucceed: true,
  143. },
  144. {
  145. name: "Generate valid presigned PUT URL",
  146. request: &PresignedURLRequest{
  147. Method: "PUT",
  148. Bucket: "test-bucket",
  149. ObjectKey: "new-file.txt",
  150. Expiration: time.Hour,
  151. SessionToken: sessionToken,
  152. },
  153. shouldSucceed: true,
  154. },
  155. {
  156. name: "Generate URL with invalid session token",
  157. request: &PresignedURLRequest{
  158. Method: "GET",
  159. Bucket: "test-bucket",
  160. ObjectKey: "test-file.txt",
  161. Expiration: time.Hour,
  162. SessionToken: "invalid-token",
  163. },
  164. shouldSucceed: false,
  165. expectedError: "IAM authorization failed",
  166. },
  167. {
  168. name: "Generate URL without session token",
  169. request: &PresignedURLRequest{
  170. Method: "GET",
  171. Bucket: "test-bucket",
  172. ObjectKey: "test-file.txt",
  173. Expiration: time.Hour,
  174. },
  175. shouldSucceed: false,
  176. expectedError: "IAM authorization failed",
  177. },
  178. }
  179. for _, tt := range tests {
  180. t.Run(tt.name, func(t *testing.T) {
  181. response, err := presignedManager.GeneratePresignedURLWithIAM(ctx, tt.request, "http://localhost:8333")
  182. if tt.shouldSucceed {
  183. assert.NoError(t, err, "Presigned URL generation should succeed")
  184. if response != nil {
  185. assert.NotEmpty(t, response.URL, "URL should not be empty")
  186. assert.Equal(t, tt.request.Method, response.Method, "Method should match")
  187. assert.True(t, response.ExpiresAt.After(time.Now()), "URL should not be expired")
  188. } else {
  189. t.Errorf("Response should not be nil when generation should succeed")
  190. }
  191. } else {
  192. assert.Error(t, err, "Presigned URL generation should fail")
  193. if tt.expectedError != "" {
  194. assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
  195. }
  196. }
  197. })
  198. }
  199. }
  200. // TestPresignedURLExpiration tests URL expiration validation
  201. func TestPresignedURLExpiration(t *testing.T) {
  202. tests := []struct {
  203. name string
  204. setupRequest func() *http.Request
  205. expectedError string
  206. }{
  207. {
  208. name: "Valid non-expired URL",
  209. setupRequest: func() *http.Request {
  210. req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
  211. q := req.URL.Query()
  212. // Set date to 30 minutes ago with 2 hours expiration for safe margin
  213. q.Set("X-Amz-Date", time.Now().UTC().Add(-30*time.Minute).Format("20060102T150405Z"))
  214. q.Set("X-Amz-Expires", "7200") // 2 hours
  215. req.URL.RawQuery = q.Encode()
  216. return req
  217. },
  218. expectedError: "",
  219. },
  220. {
  221. name: "Expired URL",
  222. setupRequest: func() *http.Request {
  223. req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
  224. q := req.URL.Query()
  225. // Set date to 2 hours ago with 1 hour expiration
  226. q.Set("X-Amz-Date", time.Now().UTC().Add(-2*time.Hour).Format("20060102T150405Z"))
  227. q.Set("X-Amz-Expires", "3600") // 1 hour
  228. req.URL.RawQuery = q.Encode()
  229. return req
  230. },
  231. expectedError: "presigned URL has expired",
  232. },
  233. {
  234. name: "Missing date parameter",
  235. setupRequest: func() *http.Request {
  236. req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
  237. q := req.URL.Query()
  238. q.Set("X-Amz-Expires", "3600")
  239. req.URL.RawQuery = q.Encode()
  240. return req
  241. },
  242. expectedError: "missing required presigned URL parameters",
  243. },
  244. {
  245. name: "Invalid date format",
  246. setupRequest: func() *http.Request {
  247. req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
  248. q := req.URL.Query()
  249. q.Set("X-Amz-Date", "invalid-date")
  250. q.Set("X-Amz-Expires", "3600")
  251. req.URL.RawQuery = q.Encode()
  252. return req
  253. },
  254. expectedError: "invalid X-Amz-Date format",
  255. },
  256. }
  257. for _, tt := range tests {
  258. t.Run(tt.name, func(t *testing.T) {
  259. req := tt.setupRequest()
  260. err := ValidatePresignedURLExpiration(req)
  261. if tt.expectedError == "" {
  262. assert.NoError(t, err, "Validation should succeed")
  263. } else {
  264. assert.Error(t, err, "Validation should fail")
  265. assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
  266. }
  267. })
  268. }
  269. }
  270. // TestPresignedURLSecurityPolicy tests security policy enforcement
  271. func TestPresignedURLSecurityPolicy(t *testing.T) {
  272. policy := &PresignedURLSecurityPolicy{
  273. MaxExpirationDuration: 24 * time.Hour,
  274. AllowedMethods: []string{"GET", "PUT"},
  275. RequiredHeaders: []string{"Content-Type"},
  276. MaxFileSize: 1024 * 1024, // 1MB
  277. }
  278. tests := []struct {
  279. name string
  280. request *PresignedURLRequest
  281. expectedError string
  282. }{
  283. {
  284. name: "Valid request",
  285. request: &PresignedURLRequest{
  286. Method: "GET",
  287. Bucket: "test-bucket",
  288. ObjectKey: "test-file.txt",
  289. Expiration: 12 * time.Hour,
  290. Headers: map[string]string{"Content-Type": "application/json"},
  291. },
  292. expectedError: "",
  293. },
  294. {
  295. name: "Expiration too long",
  296. request: &PresignedURLRequest{
  297. Method: "GET",
  298. Bucket: "test-bucket",
  299. ObjectKey: "test-file.txt",
  300. Expiration: 48 * time.Hour, // Exceeds 24h limit
  301. Headers: map[string]string{"Content-Type": "application/json"},
  302. },
  303. expectedError: "expiration duration",
  304. },
  305. {
  306. name: "Method not allowed",
  307. request: &PresignedURLRequest{
  308. Method: "DELETE", // Not in allowed methods
  309. Bucket: "test-bucket",
  310. ObjectKey: "test-file.txt",
  311. Expiration: 12 * time.Hour,
  312. Headers: map[string]string{"Content-Type": "application/json"},
  313. },
  314. expectedError: "HTTP method DELETE is not allowed",
  315. },
  316. {
  317. name: "Missing required header",
  318. request: &PresignedURLRequest{
  319. Method: "GET",
  320. Bucket: "test-bucket",
  321. ObjectKey: "test-file.txt",
  322. Expiration: 12 * time.Hour,
  323. Headers: map[string]string{}, // Missing Content-Type
  324. },
  325. expectedError: "required header Content-Type is missing",
  326. },
  327. }
  328. for _, tt := range tests {
  329. t.Run(tt.name, func(t *testing.T) {
  330. err := policy.ValidatePresignedURLRequest(tt.request)
  331. if tt.expectedError == "" {
  332. assert.NoError(t, err, "Policy validation should succeed")
  333. } else {
  334. assert.Error(t, err, "Policy validation should fail")
  335. assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
  336. }
  337. })
  338. }
  339. }
  340. // TestS3ActionDetermination tests action determination from HTTP methods
  341. func TestS3ActionDetermination(t *testing.T) {
  342. tests := []struct {
  343. name string
  344. method string
  345. bucket string
  346. object string
  347. expectedAction Action
  348. }{
  349. {
  350. name: "GET object",
  351. method: "GET",
  352. bucket: "test-bucket",
  353. object: "test-file.txt",
  354. expectedAction: s3_constants.ACTION_READ,
  355. },
  356. {
  357. name: "GET bucket (list)",
  358. method: "GET",
  359. bucket: "test-bucket",
  360. object: "",
  361. expectedAction: s3_constants.ACTION_LIST,
  362. },
  363. {
  364. name: "PUT object",
  365. method: "PUT",
  366. bucket: "test-bucket",
  367. object: "new-file.txt",
  368. expectedAction: s3_constants.ACTION_WRITE,
  369. },
  370. {
  371. name: "DELETE object",
  372. method: "DELETE",
  373. bucket: "test-bucket",
  374. object: "old-file.txt",
  375. expectedAction: s3_constants.ACTION_WRITE,
  376. },
  377. {
  378. name: "DELETE bucket",
  379. method: "DELETE",
  380. bucket: "test-bucket",
  381. object: "",
  382. expectedAction: s3_constants.ACTION_DELETE_BUCKET,
  383. },
  384. {
  385. name: "HEAD object",
  386. method: "HEAD",
  387. bucket: "test-bucket",
  388. object: "test-file.txt",
  389. expectedAction: s3_constants.ACTION_READ,
  390. },
  391. {
  392. name: "POST object",
  393. method: "POST",
  394. bucket: "test-bucket",
  395. object: "upload-file.txt",
  396. expectedAction: s3_constants.ACTION_WRITE,
  397. },
  398. }
  399. for _, tt := range tests {
  400. t.Run(tt.name, func(t *testing.T) {
  401. action := determineS3ActionFromMethodAndPath(tt.method, tt.bucket, tt.object)
  402. assert.Equal(t, tt.expectedAction, action, "S3 action should match expected")
  403. })
  404. }
  405. }
  406. // Helper functions for tests
  407. func setupTestIAMManagerForPresigned(t *testing.T) *integration.IAMManager {
  408. // Create IAM manager
  409. manager := integration.NewIAMManager()
  410. // Initialize with test configuration
  411. config := &integration.IAMConfig{
  412. STS: &sts.STSConfig{
  413. TokenDuration: sts.FlexibleDuration{time.Hour},
  414. MaxSessionLength: sts.FlexibleDuration{time.Hour * 12},
  415. Issuer: "test-sts",
  416. SigningKey: []byte("test-signing-key-32-characters-long"),
  417. },
  418. Policy: &policy.PolicyEngineConfig{
  419. DefaultEffect: "Deny",
  420. StoreType: "memory",
  421. },
  422. Roles: &integration.RoleStoreConfig{
  423. StoreType: "memory",
  424. },
  425. }
  426. err := manager.Initialize(config, func() string {
  427. return "localhost:8888" // Mock filer address for testing
  428. })
  429. require.NoError(t, err)
  430. // Set up test identity providers
  431. setupTestProvidersForPresigned(t, manager)
  432. return manager
  433. }
  434. func setupTestProvidersForPresigned(t *testing.T, manager *integration.IAMManager) {
  435. // Set up OIDC provider
  436. oidcProvider := oidc.NewMockOIDCProvider("test-oidc")
  437. oidcConfig := &oidc.OIDCConfig{
  438. Issuer: "https://test-issuer.com",
  439. ClientID: "test-client-id",
  440. }
  441. err := oidcProvider.Initialize(oidcConfig)
  442. require.NoError(t, err)
  443. oidcProvider.SetupDefaultTestData()
  444. // Set up LDAP provider
  445. ldapProvider := ldap.NewMockLDAPProvider("test-ldap")
  446. err = ldapProvider.Initialize(nil) // Mock doesn't need real config
  447. require.NoError(t, err)
  448. ldapProvider.SetupDefaultTestData()
  449. // Register providers
  450. err = manager.RegisterIdentityProvider(oidcProvider)
  451. require.NoError(t, err)
  452. err = manager.RegisterIdentityProvider(ldapProvider)
  453. require.NoError(t, err)
  454. }
  455. func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMManager) {
  456. // Create read-only policy
  457. readOnlyPolicy := &policy.PolicyDocument{
  458. Version: "2012-10-17",
  459. Statement: []policy.Statement{
  460. {
  461. Sid: "AllowS3ReadOperations",
  462. Effect: "Allow",
  463. Action: []string{"s3:GetObject", "s3:ListBucket", "s3:HeadObject"},
  464. Resource: []string{
  465. "arn:seaweed:s3:::*",
  466. "arn:seaweed:s3:::*/*",
  467. },
  468. },
  469. },
  470. }
  471. manager.CreatePolicy(ctx, "", "S3ReadOnlyPolicy", readOnlyPolicy)
  472. // Create read-only role
  473. manager.CreateRole(ctx, "", "S3ReadOnlyRole", &integration.RoleDefinition{
  474. RoleName: "S3ReadOnlyRole",
  475. TrustPolicy: &policy.PolicyDocument{
  476. Version: "2012-10-17",
  477. Statement: []policy.Statement{
  478. {
  479. Effect: "Allow",
  480. Principal: map[string]interface{}{
  481. "Federated": "test-oidc",
  482. },
  483. Action: []string{"sts:AssumeRoleWithWebIdentity"},
  484. },
  485. },
  486. },
  487. AttachedPolicies: []string{"S3ReadOnlyPolicy"},
  488. })
  489. // Create admin policy
  490. adminPolicy := &policy.PolicyDocument{
  491. Version: "2012-10-17",
  492. Statement: []policy.Statement{
  493. {
  494. Sid: "AllowAllS3Operations",
  495. Effect: "Allow",
  496. Action: []string{"s3:*"},
  497. Resource: []string{
  498. "arn:seaweed:s3:::*",
  499. "arn:seaweed:s3:::*/*",
  500. },
  501. },
  502. },
  503. }
  504. manager.CreatePolicy(ctx, "", "S3AdminPolicy", adminPolicy)
  505. // Create admin role
  506. manager.CreateRole(ctx, "", "S3AdminRole", &integration.RoleDefinition{
  507. RoleName: "S3AdminRole",
  508. TrustPolicy: &policy.PolicyDocument{
  509. Version: "2012-10-17",
  510. Statement: []policy.Statement{
  511. {
  512. Effect: "Allow",
  513. Principal: map[string]interface{}{
  514. "Federated": "test-oidc",
  515. },
  516. Action: []string{"sts:AssumeRoleWithWebIdentity"},
  517. },
  518. },
  519. },
  520. AttachedPolicies: []string{"S3AdminPolicy"},
  521. })
  522. // Create a role for presigned URL users with admin permissions for testing
  523. manager.CreateRole(ctx, "", "PresignedUser", &integration.RoleDefinition{
  524. RoleName: "PresignedUser",
  525. TrustPolicy: &policy.PolicyDocument{
  526. Version: "2012-10-17",
  527. Statement: []policy.Statement{
  528. {
  529. Effect: "Allow",
  530. Principal: map[string]interface{}{
  531. "Federated": "test-oidc",
  532. },
  533. Action: []string{"sts:AssumeRoleWithWebIdentity"},
  534. },
  535. },
  536. },
  537. AttachedPolicies: []string{"S3AdminPolicy"}, // Use admin policy for testing
  538. })
  539. }
  540. func createPresignedURLRequest(t *testing.T, method, path, sessionToken string) *http.Request {
  541. req := httptest.NewRequest(method, path, nil)
  542. // Add presigned URL parameters if session token is provided
  543. if sessionToken != "" {
  544. q := req.URL.Query()
  545. q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
  546. q.Set("X-Amz-Security-Token", sessionToken)
  547. q.Set("X-Amz-Date", time.Now().Format("20060102T150405Z"))
  548. q.Set("X-Amz-Expires", "3600")
  549. req.URL.RawQuery = q.Encode()
  550. }
  551. return req
  552. }