feat: must have a 'owner' access key before configuring CORS

This commit is contained in:
jpedro-cf 2025-11-07 20:51:12 -03:00
parent 9344247d19
commit 48211dd50e
7 changed files with 154 additions and 117 deletions

View File

@ -33,7 +33,7 @@ func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) {
limit = 100
}
client, err := getS3Client(bucket)
client, err := getS3Client(bucket, nil)
if err != nil {
utils.ResponseError(w, err)
return
@ -88,7 +88,7 @@ func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) {
thumbnail := queryParams.Get("thumb") == "1"
download := queryParams.Get("dl") == "1"
client, err := getS3Client(bucket)
client, err := getS3Client(bucket, nil)
if err != nil {
utils.ResponseError(w, err)
return
@ -184,7 +184,7 @@ func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) {
defer file.Close()
}
client, err := getS3Client(bucket)
client, err := getS3Client(bucket, nil)
if err != nil {
utils.ResponseError(w, err)
return
@ -220,7 +220,7 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
recursive := r.URL.Query().Get("recursive") == "true"
isDirectory := strings.HasSuffix(key, "/")
client, err := getS3Client(bucket)
client, err := getS3Client(bucket, nil)
if err != nil {
utils.ResponseError(w, err)
return
@ -284,10 +284,15 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
utils.ResponseSuccess(w, res)
}
func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {
func getBucketCredentials(bucket string, permission *string) (aws.CredentialsProvider, error) {
cacheKey := fmt.Sprintf("key:%s", bucket)
cacheData := utils.Cache.Get(cacheKey)
// Some CORS operations with the SDK are only achievable using a "owner" access key
if permission != nil {
cacheKey = fmt.Sprintf("key:%s:%s", *permission, bucket)
}
cacheData := utils.Cache.Get(cacheKey)
if cacheData != nil {
return cacheData.(aws.CredentialsProvider), nil
}
@ -305,7 +310,10 @@ func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {
var key schema.KeyElement
for _, k := range bucketData.Keys {
if !k.Permissions.Read || !k.Permissions.Write {
if permission == nil && (!k.Permissions.Read || !k.Permissions.Write){
continue
}
if permission != nil && !k.Permissions.HasPermission(*permission) {
continue
}
@ -319,14 +327,18 @@ func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {
break
}
if key.AccessKeyID == "" {
return nil, fmt.Errorf("a valid access key was not found for this bucket")
}
credential := credentials.NewStaticCredentialsProvider(key.AccessKeyID, key.SecretAccessKey, "")
utils.Cache.Set(cacheKey, credential, time.Hour)
return credential, nil
}
func getS3Client(bucket string) (*s3.Client, error) {
creds, err := getBucketCredentials(bucket)
func getS3Client(bucket string, permission *string) (*s3.Client, error) {
creds, err := getBucketCredentials(bucket, permission)
if err != nil {
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucket, err)
}

View File

@ -61,20 +61,33 @@ func (b *Buckets) GetAll(w http.ResponseWriter, r *http.Request) {
func (b*Buckets) GetCors(w http.ResponseWriter, r *http.Request){
bucket := r.PathValue("bucket")
client, err := getS3Client(bucket)
perm := "owner"
client, err := getS3Client(bucket, &perm)
if err != nil {
utils.ResponseError(w, err)
return
}
out, err := client.GetBucketCors(context.Background(), &s3.GetBucketCorsInput{
cors, err := client.GetBucketCors(context.Background(), &s3.GetBucketCorsInput{
Bucket: aws.String(bucket),
})
res := []schema.BucketCors{}
if err != nil {
utils.ResponseError(w, err)
return
}
for _, rule := range out.CORSRules {
var defaultCors = []schema.BucketCors{{
AllowedOrigins: []string{},
AllowedMethods: []string{},
AllowedHeaders: []string{},
ExposeHeaders: []string{},
MaxAgeSeconds: nil,
}}
res := make([]schema.BucketCors, 0, len(cors.CORSRules))
for _, rule := range cors.CORSRules {
res = append(res, schema.BucketCors{
AllowedOrigins: rule.AllowedOrigins,
AllowedMethods: rule.AllowedMethods,
@ -84,9 +97,8 @@ func (b*Buckets) GetCors(w http.ResponseWriter, r *http.Request){
})
}
if err != nil {
utils.ResponseError(w, err)
return
if len(res) == 0 {
res = defaultCors
}
utils.ResponseSuccess(w, res)
@ -95,7 +107,6 @@ func (b*Buckets) GetCors(w http.ResponseWriter, r *http.Request){
func (b*Buckets) PutCors(w http.ResponseWriter, r *http.Request){
bucket := r.PathValue("bucket")
var body struct {
Rules []schema.BucketCors `json:"rules"`
}
@ -105,7 +116,8 @@ func (b*Buckets) PutCors(w http.ResponseWriter, r *http.Request){
return
}
client, err := getS3Client(bucket)
perm := "owner"
client, err := getS3Client(bucket, &perm)
if err != nil {
utils.ResponseError(w, err)
@ -115,19 +127,19 @@ func (b*Buckets) PutCors(w http.ResponseWriter, r *http.Request){
cors := make([]types.CORSRule, 0, len(body.Rules))
for _, rule := range body.Rules {
if len(rule.AllowedMethods) == 0 || len(rule.AllowedOrigins) == 0 {
utils.ResponseError(w, fmt.Errorf("each CORS rule must have at least one allowed method and origin"))
return
}
if len(rule.AllowedMethods) == 0 || len(rule.AllowedOrigins) == 0 {
utils.ResponseError(w, fmt.Errorf("each CORS rule must have at least one allowed method and origin"))
return
}
cors = append(cors, types.CORSRule{
AllowedHeaders: utils.NilIfEmpty(rule.AllowedHeaders),
AllowedMethods: rule.AllowedMethods, // required
AllowedOrigins: rule.AllowedOrigins, // required
ExposeHeaders: utils.NilIfEmpty(rule.ExposeHeaders),
MaxAgeSeconds: rule.MaxAgeSeconds,
})
}
cors = append(cors, types.CORSRule{
AllowedHeaders: utils.NilIfEmpty(rule.AllowedHeaders),
AllowedMethods: rule.AllowedMethods, // required
AllowedOrigins: rule.AllowedOrigins, // required
ExposeHeaders: utils.NilIfEmpty(rule.ExposeHeaders),
MaxAgeSeconds: rule.MaxAgeSeconds,
})
}
res, err := client.PutBucketCors(context.Background(), &s3.PutBucketCorsInput{
Bucket: aws.String(bucket),

View File

@ -60,3 +60,16 @@ type WebsiteConfig struct {
IndexDocument string `json:"indexDocument"`
ErrorDocument string `json:"errorDocument"`
}
func (p *Permissions) HasPermission(perm string) bool {
switch perm {
case "read":
return p.Read
case "write":
return p.Write
case "owner":
return p.Owner
default:
return false
}
}

View File

@ -117,6 +117,7 @@ export const useBucketCors = (bucketName?: string) => {
queryKey: ["bucket_cors", bucketName],
queryFn: () => api.get<BucketCors[]>(`/buckets/${bucketName}/cors`),
enabled: !!bucketName,
retry: false,
});
};

View File

@ -39,7 +39,9 @@ const tabs: Tab[] = [
const ManageBucketPage = () => {
const { id } = useParams();
const { data, error, isLoading, refetch } = useBucket(id);
const { data: cors } = useBucketCors(data?.globalAliases[0]);
const { data: cors, isLoading: corsLoading } = useBucketCors(
data?.globalAliases[0]
);
const name = data?.globalAliases[0];
@ -63,7 +65,7 @@ const ManageBucketPage = () => {
</Alert>
)}
{data && cors && (
{data && !corsLoading && (
<div className="container">
<BucketContext.Provider
value={{

View File

@ -19,7 +19,7 @@ type Props = {
};
const AllowKeyDialog = ({ currentKeys }: Props) => {
const { bucket } = useBucketContext();
const { bucket, bucketName } = useBucketContext();
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
const { data: keys } = useKeys();
const form = useForm<AllowKeysSchema>({
@ -37,6 +37,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
onClose();
toast.success("Key allowed!");
queryClient.invalidateQueries({ queryKey: ["bucket", bucket.id] });
queryClient.invalidateQueries({ queryKey: ["bucket_cors", bucketName] });
},
onError: handleError,
});

View File

@ -1,12 +1,12 @@
import { Card, Modal } from "react-daisyui";
import { Alert, Card, Modal } from "react-daisyui";
import Button from "@/components/ui/button";
import { CheckCircle, Plus } from "lucide-react";
import { CheckCircle, CircleXIcon, Plus } from "lucide-react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { useBucketContext } from "../context";
import { bucketCorsSchema, BucketCorsSchema } from "../schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { Path, useFieldArray, useForm, UseFormReturn } from "react-hook-form";
import { Path, useForm, UseFormReturn } from "react-hook-form";
import Input, { InputField } from "@/components/ui/input";
import FormControl from "@/components/ui/form-control";
import { useDisclosure } from "@/hooks/useDisclosure";
@ -17,7 +17,7 @@ import { useQueryClient } from "@tanstack/react-query";
const CorsConfiguration = () => {
const queryClient = useQueryClient();
const { bucketName, cors } = useBucketContext();
const { bucketName, cors, bucket } = useBucketContext();
const { mutate, isPending } = useBucketCorsMutation({
onSuccess: () => {
@ -31,30 +31,18 @@ const CorsConfiguration = () => {
resolver: zodResolver(bucketCorsSchema),
defaultValues: {
bucketName: bucketName,
rules:
cors.length > 0
? cors
: [
{
allowedHeaders: [],
allowedMethods: [],
allowedOrigins: [],
exposeHeaders: [],
maxAgeSeconds: null,
},
],
rules: cors,
},
});
const { fields } = useFieldArray({
control: form.control,
name: "rules",
});
const fields = form.watch("rules");
function handleSubmit(data: BucketCorsSchema) {
mutate(data);
}
const allowConfiguration = bucket.keys.some((key) => key.permissions.owner);
return (
<Card className="card-body">
<form onSubmit={form.handleSubmit(handleSubmit)}>
@ -66,74 +54,82 @@ const CorsConfiguration = () => {
icon={CheckCircle}
color="primary"
type="submit"
disabled={isPending}
disabled={isPending || !allowConfiguration}
>
Save
</Button>
</div>
{fields.map((rule, idx) => (
<div
className="grid md:grid-cols-2 xl:grid-cols-5 gap-5 mt-5"
key={rule.id}
>
<FormControl
form={form}
name={`rules.${idx}.allowedHeaders`}
title={"Allowed Headers"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.allowedHeaders`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<FormControl
form={form}
name={`rules.${idx}.allowedMethods`}
title={"Allowed Methods"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.allowedMethods`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<FormControl
form={form}
name={`rules.${idx}.allowedOrigins`}
title={"Allowed Origins"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.allowedOrigins`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<FormControl
key={rule.id}
form={form}
name={`rules.${idx}.exposeHeaders`}
title={"Expose Headers"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.exposeHeaders`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<InputField
form={form}
name={`rules.${idx}.maxAgeSeconds`}
title="Max age seconds"
placeholder="0000"
type="number"
/>
</div>
))}
{!allowConfiguration && (
<Alert status="warning" icon={<CircleXIcon />} className="mt-5">
<span>
You must configure an access key with owner permissions for this
bucket before setting up CORS.
</span>
</Alert>
)}
{allowConfiguration &&
fields.map((_, idx) => (
<div
className="grid md:grid-cols-2 xl:grid-cols-5 gap-5 mt-5"
key={idx}
>
<FormControl
form={form}
name={`rules.${idx}.allowedHeaders`}
title={"Allowed Headers"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.allowedHeaders`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<FormControl
form={form}
name={`rules.${idx}.allowedMethods`}
title={"Allowed Methods"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.allowedMethods`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<FormControl
form={form}
name={`rules.${idx}.allowedOrigins`}
title={"Allowed Origins"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.allowedOrigins`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<FormControl
form={form}
name={`rules.${idx}.exposeHeaders`}
title={"Expose Headers"}
render={(field) => (
<CorsRulesChips
form={form}
fieldName={`rules.${idx}.exposeHeaders`}
values={(field.value as string[]) ?? []}
/>
)}
/>
<InputField
form={form}
name={`rules.${idx}.maxAgeSeconds`}
title="Max age seconds"
placeholder="0000"
type="number"
/>
</div>
))}
</form>
</Card>
);