diff --git a/CLAUDE.md b/CLAUDE.md index 81231b1..42c1f23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ The application follows a command-line interface pattern using the Cobra library - **main.go**: Core CLI structure with command definitions and configuration management - **auth.go**: OAuth2 device flow authentication with JWT token handling - **datasets.go**: Dataset operations (list, download, upload, status) with REST API integration -- **registries.go**: Registry operations (list, config, add, update) with REST API integration +- **registries.go**: Registry operations (list, config, add, update, registrator) with REST API integration - **packages.go**: Package search with REST API primary path (`/packages/info`) and GraphQL fallback - **projects.go**: Project management using GraphQL API with user filtering - **user.go**: User information retrieval using GraphQL API and REST API for listing users @@ -32,9 +32,9 @@ The application follows a command-line interface pattern using the Cobra library - Stores tokens securely in `~/.juliahub` with 0600 permissions 2. **API Integration**: - - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), and landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE) - - **GraphQL API**: Used for projects, user info, and package search/info fallback (`/v1/graphql`) - - **Headers**: All GraphQL requests require `X-Hasura-Role: jhuser` header + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`, `/api/v1/registry/config/registrator/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), admin group management (`/app/config/groups`), and landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE) + - **GraphQL API**: Used for projects, user info, user list (`public_users`), group list, and package search/info fallback (`/v1/graphql`) + - **Headers**: All GraphQL requests require `Authorization: Bearer `, `X-Hasura-Role: jhuser`, and `X-Juliahub-Ensure-JS: true` - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls 3. **Command Structure**: @@ -42,13 +42,16 @@ The application follows a command-line interface pattern using the Cobra library - `jh dataset`: Dataset operations (list, download, upload, status) - `jh registry`: Registry operations (list, config — all via REST API) - `jh registry config`: Show registry JSON config by name; subcommands add/update accept JSON via stdin or `--file` + - `jh registry permission`: Registry permission management (list, set, remove) + - `jh registry registrator`: Show registrator config by name; subcommand update accepts JSON via stdin or `--file` - `jh package`: Package search and info (REST primary via `/packages/info`, GraphQL fallback; supports filtering by registry) - `jh project`: Project management (list with GraphQL, supports user filtering) - - `jh package`: Package search (REST primary via `/packages/info`, GraphQL fallback) - - `jh user`: User information (info with GraphQL) - - `jh admin`: Administrative commands (user management, token management, landing page) + - `jh user`: User information (info, list via GraphQL `public_users`) + - `jh group`: Group information (list via GraphQL) + - `jh admin`: Administrative commands (user management, token management, group management, landing page) - `jh admin user`: User management (list all users with REST API, supports verbose mode) - `jh admin token`: Token management (list all tokens with REST API, supports verbose mode) + - `jh admin group`: Group management (list all groups via REST API) - `jh admin landing-page`: Landing page management (show/update/remove custom markdown landing page with REST API) - `jh clone`: Git clone with JuliaHub authentication and project name resolution - `jh push/fetch/pull`: Git operations with JuliaHub authentication @@ -123,13 +126,32 @@ echo '{ "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg.juliahub.com", - "credential_key": "JC Auth Token" + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] }' | go run . registry config add go run . registry config add --file registry.json # Update an existing registry (same JSON schema, same flags) go run . registry config update --file registry.json + +# Show registrator config for a registry +go run . registry registrator MyRegistry + +# Update registrator config (JSON via stdin or --file) +echo '{ + "enabled": true, + "email": "pkg@example.com", + "authorization": true, + "ssl_verify": true, + "registry_fork_url": null, + "registry_deps": ["General"] +}' | go run . registry registrator update MyRegistry +go run . registry registrator update MyRegistry --file registrator.json + +# Get, edit, push back +go run . registry registrator MyRegistry > registrator.json +go run . registry registrator update MyRegistry --file registrator.json ``` ### Test project and user operations @@ -138,8 +160,11 @@ go run . project list go run . project list --user go run . project list --user john go run . user info +go run . user list +go run . group list go run . admin user list go run . admin user list --verbose +go run . admin group list ``` ### Test token operations @@ -331,7 +356,7 @@ jh run setup ## Development Notes - All ID fields in GraphQL responses should be typed correctly (string for UUIDs, int64 for user IDs) -- GraphQL queries are embedded as strings (consider external .gql files for complex queries) +- GraphQL queries are embedded at compile time using `go:embed` from `.gql` files (`userinfo.gql`, `users.gql`, `groups.gql`, `projects.gql`) - Error handling includes both HTTP and GraphQL error responses - Token refresh is automatic via `ensureValidToken()` - File uploads use multipart form data with proper content types @@ -347,13 +372,20 @@ jh run setup - Clone command supports `project` (without username) and defaults to the logged-in user's username - Folder naming conflicts are resolved with automatic numbering (project-1, project-2, etc.) - Credential helper follows Git protocol: responds only to JuliaHub URLs, ignores others +- `jh user list` uses GraphQL `public_users` query (via `users.gql`) and displays ` ()` per line +- `jh group list` uses GraphQL groups query (via `groups.gql`) and displays one group name per line - Admin user list command (`jh admin user list`) uses REST API endpoint `/app/config/features/manage` which requires appropriate permissions -- User list output is concise by default (Name and Email only); use `--verbose` flag for detailed information (UUID, groups, features) +- Admin group list command (`jh admin group list`) uses REST API endpoint `/app/config/groups` which requires appropriate permissions +- Admin user list output is compact by default (` ()`); use `--verbose` flag for detailed information (UUID, groups, features) - Registry list output is concise by default (UUID and Name only); use `--verbose` flag for detailed information (owner, creation date, package count, description) - Registry config command (`jh registry config `) uses REST API endpoint `/api/v1/registry/config/registry/{name}` (GET) and prints the full JSON response - Registry add/update commands (`jh registry config add` / `jh registry config update`) use REST API endpoint `/api/v1/registry/config/registry/{name}` (POST); the backend creates or updates based on whether the registry already exists - Both commands accept the full registry JSON payload via `--file ` or stdin; the payload `name` field identifies the registry - Registry add/update always poll `/api/v1/registry/config/registry/{name}/savestatus` every 3 seconds up to a 2-minute timeout +- Registry registrator command (`jh registry registrator `) uses REST API endpoint `/api/v1/registry/config/registrator/{name}` (GET) and prints the full JSON response +- Registry registrator update command (`jh registry registrator update `) uses REST API endpoint `/api/v1/registry/config/registrator/{name}` (POST); the registry name comes from the positional argument (`RegistratorInfo` has no `name` field) +- Registrator update validates that `"email"` is non-empty when `"enabled"` is true +- GET returns 404 "Registry not found" when no registrator has been configured for that registry yet - Bundle provider type automatically sets `license_detect: false` in the payload - Admin token list command (`jh admin token list`) uses REST API endpoint `/app/token/activelist` which requires appropriate permissions - Token list output is concise by default (Subject, Created By, and Expired status only); use `--verbose` flag for detailed information (signature, creation date, expiration date with estimate indicator) @@ -392,6 +424,12 @@ jh run setup - `submitRegistry` POSTs to `/api/v1/registry/config/registry/{name}` with retry on 500s, then calls `pollRegistrySaveStatus()` - `pollRegistrySaveStatus` GETs `/api/v1/registry/config/registry/{name}/savestatus` every 3 seconds up to a 2-minute deadline +**`jh registry registrator ` / `jh registry registrator update`:** + +- `getRegistrator` uses `apiGet` to GET `/api/v1/registry/config/registrator/{name}` and pretty-prints the JSON response +- `setRegistrator(server, name, filePath)` reads `RegistratorInfo` JSON from `--file` or stdin, validates `"email"` is set when `"enabled"` is true, then POSTs to `/api/v1/registry/config/registrator/{name}` +- No polling — the POST response is the final result + ### Julia Credentials Management (`run.go`) The Julia credentials system consists of three main functions: diff --git a/README.md b/README.md index 390368b..f67c69c 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Project Management**: List and filter projects using GraphQL API - **Git Integration**: Clone, push, fetch, and pull with automatic JuliaHub authentication - **Julia Integration**: Install Julia and run with JuliaHub package server configuration -- **User Management**: Display user information and view profile details -- **Administrative Commands**: Manage users, tokens, and system resources (requires admin permissions) +- **User Management**: Display user information, list users and groups +- **Administrative Commands**: Manage users, groups, tokens, and system resources (requires admin permissions) ## Installation @@ -170,6 +170,9 @@ go build -o jh . - `jh registry config ` - Show the full JSON configuration for a registry - `jh registry config add` - Add a new registry (JSON payload via stdin or `--file`) - `jh registry config update` - Update an existing registry (same JSON schema as add, same flags) +- `jh registry permission list ` - List permissions for a registry +- `jh registry permission set --user|--group --privilege download|register` - Add or update a permission +- `jh registry permission remove --user|--group ` - Remove a permission ### Project Management (`jh project`) @@ -197,15 +200,23 @@ go build -o jh . ### User Information (`jh user`) -- `jh user info` - Show detailed user information +- `jh user info` - Show detailed information about the logged-in user +- `jh user list` - List all users (` ()` format, via GraphQL) + +### Group Information (`jh group`) + +- `jh group list` - List all groups (one per line, via GraphQL) ### Administrative Commands (`jh admin`) #### User Management - `jh admin user list` - List all users (requires appropriate permissions) - - Default: Shows only Name and Email + - Default: Shows ` ()` per line - `jh admin user list --verbose` - Show detailed user information including UUID, groups, and features +#### Group Management +- `jh admin group list` - List all groups via REST API (requires appropriate permissions) + #### Token Management - `jh admin token list` - List all tokens (requires appropriate permissions) - Default: Shows only Subject, Created By, and Expired status @@ -303,7 +314,8 @@ echo '{ "enabled": true, "display_apps": true, "owner": "", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg.juliahub.com", - "credential_key": "JC Auth Token" + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] }' | jh registry config add @@ -327,6 +339,16 @@ jh project list --user jh project list --user alice ``` +### User and Group Operations + +```bash +# List users (GraphQL) +jh user list + +# List groups (GraphQL) +jh group list +``` + ### Administrative Operations ```bash @@ -336,6 +358,9 @@ jh admin user list # List users with detailed information jh admin user list --verbose +# List all groups via REST (requires admin permissions) +jh admin group list + # List all tokens (requires admin permissions) jh admin token list diff --git a/groups.gql b/groups.gql new file mode 100644 index 0000000..02394aa --- /dev/null +++ b/groups.gql @@ -0,0 +1,12 @@ +query Groups($name: String = "%", $limit: Int = 100) { + groups(where: { name: { _ilike: $name } }, limit: $limit) { + name + group_id + } + products { + name + display_name + id + compute_type_name + } +} diff --git a/main.go b/main.go index 50d5dcb..94b3540 100644 --- a/main.go +++ b/main.go @@ -621,15 +621,26 @@ PROVIDER TYPES cacheserver — sync from a JuliaHub package cache: { - "type": "cacheserver", - "host": "", - "credential_key": "" + "type": "cacheserver", + "host": "", + "credential_key": "", + "server_type": "", + "github_credential_type": "", + "api_host": "", + "url": "", + "user_name": "" } bundle — local bundle (sets license_detect: false automatically): { - "type": "bundle", - "credential_key": "" + "type": "bundle", + "credential_key": "", + "server_type": "", + "github_credential_type": "", + "api_host": "", + "url": "", + "user_name": "", + "host": "" } genericserver — generic server with basic auth: @@ -861,7 +872,8 @@ var registryConfigAddCmd = &cobra.Command{ "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg.juliahub.com", - "credential_key": "JC Auth Token" + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] }' | jh registry config add @@ -921,7 +933,8 @@ var registryConfigUpdateCmd = &cobra.Command{ "enabled": true, "display_apps": true, "owner": "admin", "sync_schedule": null, "download_providers": [{ "type": "cacheserver", "host": "https://pkg-new.juliahub.com", - "credential_key": "JC Auth Token" + "credential_key": "JC Auth Token", + "server_type": "", "github_credential_type": "", "api_host": "", "url": "", "user_name": "" }] }' | jh registry config update @@ -997,6 +1010,156 @@ Use --verbose flag to display comprehensive information including: }, } +var registryPermissionCmd = &cobra.Command{ + Use: "permission", + Short: "Manage registry permissions", + Long: `Manage access permissions for a Julia package registry. + +Permissions control which users and groups can access the registry. +Supported privilege levels: + download - read-only access to download packages + register - download access plus ability to register packages + +The registry owner and admins can always manage permissions regardless of settings.`, +} + +var registryPermissionListCmd = &cobra.Command{ + Use: "list ", + Short: "List permissions for a registry", + Example: " jh registry permission list MyRegistry\n jh registry permission list MyRegistry -s custom.juliahub.com", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listRegistryPermissions(server, args[0]); err != nil { + fmt.Printf("Failed to list permissions: %v\n", err) + os.Exit(1) + } + }, +} + +var registryPermissionSetCmd = &cobra.Command{ + Use: "set ", + Short: "Add or update a permission for a user or group", + Long: `Add or update access permission for a user or group on a registry. + +Exactly one of --user or --group must be provided. +Privilege must be 'download' or 'register'.`, + Example: " jh registry permission set MyRegistry --user alice --privilege download\n jh registry permission set MyRegistry --group devs --privilege register", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + user, _ := cmd.Flags().GetString("user") + group, _ := cmd.Flags().GetString("group") + privilege, _ := cmd.Flags().GetString("privilege") + if user == "" && group == "" { + fmt.Println("Error: one of --user or --group is required") + os.Exit(1) + } + if user != "" && group != "" { + fmt.Println("Error: only one of --user or --group may be specified") + os.Exit(1) + } + if privilege == "" { + fmt.Println("Error: --privilege is required (download or register)") + os.Exit(1) + } + if err := setRegistryPermission(server, args[0], user, group, privilege); err != nil { + fmt.Printf("Failed to set permission: %v\n", err) + os.Exit(1) + } + }, +} + +var registryPermissionRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a permission for a user or group", + Long: `Remove access permission for a user or group from a registry. + +Exactly one of --user or --group must be provided.`, + Example: " jh registry permission remove MyRegistry --user alice\n jh registry permission remove MyRegistry --group devs", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + user, _ := cmd.Flags().GetString("user") + group, _ := cmd.Flags().GetString("group") + if user == "" && group == "" { + fmt.Println("Error: one of --user or --group is required") + os.Exit(1) + } + if user != "" && group != "" { + fmt.Println("Error: only one of --user or --group may be specified") + os.Exit(1) + } + if err := removeRegistryPermission(server, args[0], user, group); err != nil { + fmt.Printf("Failed to remove permission: %v\n", err) + os.Exit(1) + } + }, +} + +var registryRegistratorCmd = &cobra.Command{ + Use: "registrator ", + Short: "Show the registrator configuration for a registry", + Example: " jh registry registrator MyRegistry\n jh registry registrator MyRegistry -s nightly.juliahub.dev", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := getRegistrator(server, args[0]); err != nil { + fmt.Printf("Failed to get registrator config: %v\n", err) + os.Exit(1) + } + }, +} + +var registryRegistratorUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update the registrator configuration for a registry", + Long: `Update the registrator configuration for a Julia package registry. + +Reads the registrator configuration from a JSON file (--file) or stdin. + +REGISTRATOR JSON SCHEMA + + { + "enabled": true, // enable/disable registrator + "email": "", // required when enabled + "authorization": true, // allow only package authors to register + "ssl_verify": true, // verify SSL certificates + "registry_fork_url": "", // URL to a forked registry with write access (optional, null to unset) + "registry_deps": ["", ...] // registries whose packages may be dependencies + }`, + Example: " jh registry registrator update MyRegistry --file registrator.json\n jh registry registrator MyRegistry | jh registry registrator update MyRegistry", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + filePath, _ := cmd.Flags().GetString("file") + if err := setRegistrator(server, args[0], filePath); err != nil { + fmt.Printf("Failed to update registrator config: %v\n", err) + os.Exit(1) + } + }, +} + var projectCmd = &cobra.Command{ Use: "project", Short: "Project management commands", @@ -1089,6 +1252,45 @@ Shows comprehensive user information including: Uses GraphQL API to fetch detailed user information.`, } +var userListGQLCmd = &cobra.Command{ + Use: "list", + Short: "List all users", + Example: " jh user list\n jh user list -s custom.juliahub.com", + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listUsersGQL(server); err != nil { + fmt.Printf("Failed to list users: %v\n", err) + os.Exit(1) + } + }, +} + +var groupCmd = &cobra.Command{ + Use: "group", + Short: "Group information commands", +} + +var groupListGQLCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Example: " jh group list\n jh group list -s custom.juliahub.com", + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listGroupsGQL(server); err != nil { + fmt.Printf("Failed to list groups: %v\n", err) + os.Exit(1) + } + }, +} + var userInfoCmd = &cobra.Command{ Use: "info", Short: "Show user information", @@ -1128,8 +1330,7 @@ Use --verbose flag to display comprehensive information including: - JuliaHub groups and site groups - Feature flags -This command uses the /app/config/features/manage endpoint which requires -appropriate permissions to view all users.`, +This command requires appropriate administrator permissions to view all users (including staged).`, Example: " jh admin user list\n jh admin user list --verbose", Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -1551,6 +1752,31 @@ Provides commands to list and manage API tokens across the JuliaHub instance. Note: These commands require appropriate administrative permissions.`, } +var adminGroupCmd = &cobra.Command{ + Use: "group", + Short: "Group management commands", + Long: `Administrative commands for managing groups on JuliaHub. + +Provides commands to list and manage groups across the JuliaHub instance.`, +} + +var groupListCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Example: " jh admin group list\n jh admin group list -s custom.juliahub.com", + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + if err := listGroups(server); err != nil { + fmt.Printf("Failed to list groups: %v\n", err) + os.Exit(1) + } + }, +} + var tokenListCmd = &cobra.Command{ Use: "list", Short: "List all tokens", @@ -1563,8 +1789,7 @@ Use --verbose flag to display comprehensive information including: - Expiration date (with estimate indicator) - Expiration status -This command uses the /app/token/activelist endpoint which requires -appropriate permissions to view all tokens.`, +This command requires appropriate permissions to view all tokens.`, Example: " jh admin token list\n jh admin token list --verbose", Run: func(cmd *cobra.Command, args []string) { server, err := getServerFromFlagOrConfig(cmd) @@ -1647,18 +1872,36 @@ func init() { registryConfigUpdateCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") registryConfigUpdateCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") registryConfigCmd.AddCommand(registryConfigAddCmd, registryConfigUpdateCmd) - registryCmd.AddCommand(registryListCmd, registryConfigCmd) + registryPermissionListCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionSetCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionSetCmd.Flags().String("user", "", "Username to set permission for") + registryPermissionSetCmd.Flags().String("group", "", "Group name to set permission for") + registryPermissionSetCmd.Flags().String("privilege", "", "Privilege level: download or register") + registryPermissionRemoveCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryPermissionRemoveCmd.Flags().String("user", "", "Username to remove permission for") + registryPermissionRemoveCmd.Flags().String("group", "", "Group name to remove permission for") + registryPermissionCmd.AddCommand(registryPermissionListCmd, registryPermissionSetCmd, registryPermissionRemoveCmd) + registryRegistratorCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryRegistratorUpdateCmd.Flags().StringP("server", "s", "", "JuliaHub server") + registryRegistratorUpdateCmd.Flags().StringP("file", "f", "", "Path to JSON config file (reads from stdin if omitted)") + registryRegistratorCmd.AddCommand(registryRegistratorUpdateCmd) + registryCmd.AddCommand(registryListCmd, registryConfigCmd, registryPermissionCmd, registryRegistratorCmd) projectCmd.AddCommand(projectListCmd) - userCmd.AddCommand(userInfoCmd) + userListGQLCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + userCmd.AddCommand(userInfoCmd, userListGQLCmd) + groupListGQLCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + groupCmd.AddCommand(groupListGQLCmd) + groupListCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + adminGroupCmd.AddCommand(groupListCmd) adminUserCmd.AddCommand(userListCmd) adminTokenCmd.AddCommand(tokenListCmd) adminLandingCmd.AddCommand(landingShowCmd, landingUpdateCmd, landingRemoveCmd) - adminCmd.AddCommand(adminUserCmd, adminTokenCmd, adminLandingCmd) + adminCmd.AddCommand(adminUserCmd, adminTokenCmd, adminGroupCmd, adminLandingCmd) juliaCmd.AddCommand(juliaInstallCmd) runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) - rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, packageCmd, registryCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) + rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, packageCmd, registryCmd, userCmd, groupCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) } func main() { diff --git a/projects.go b/projects.go index 13fbcd6..e73c6a0 100644 --- a/projects.go +++ b/projects.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "encoding/json" "fmt" "io" @@ -10,6 +11,9 @@ import ( "time" ) +//go:embed projects.gql +var projectsQuery string + type Project struct { ID string `json:"id"` ProjectID string `json:"project_id"` @@ -128,107 +132,7 @@ func listProjects(server string, userFilter string, userFilterProvided bool) err return fmt.Errorf("failed to get user info: %w", err) } - // Read the GraphQL query from projects.gql - query := `query Projects( - $limit: Int - $offset: Int - $orderBy: [projects_order_by!] - $ownerId: bigint - $filter: projects_bool_exp - ) { - aggregate: projects_aggregate(where: $filter) { - aggregate { - count - } - } - projects(limit: $limit, offset: $offset, order_by: $orderBy, where: $filter) { - id: project_id - project_id - name - owner { - username - name - } - created_at - product_id - finished - is_archived - instance_default_role - deployable - project_deployments_aggregate { - aggregate { - count - } - } - running_deployments: project_deployments_aggregate( - where: { - status: { _eq: "JobQueued" } - job: { status: { _eq: "Running" } } - } - ) { - aggregate { - count - } - } - pending_deployments: project_deployments_aggregate( - where: { - status: { _eq: "JobQueued" } - job: { status: { _in: ["SubmitInitialized", "Submitted", "Pending"] } } - } - ) { - aggregate { - count - } - } - resources(order_by: [{ sorting_order: asc_nulls_last }]) { - sorting_order - instance_default_role - giturl - name - resource_id - resource_type - } - product { - id - displayName: display_name - name - } - visibility - description - users: groups(where: { group_id: { _is_null: true } }) { - user { - name - } - id - assigned_role - } - groups(where: { group_id: { _is_null: false } }) { - group { - name - group_id - } - id: group_id - group_id - project_id - assigned_role - } - tags - userRole: access_control_users_aggregate( - where: { user_id: { _eq: $ownerId } } - ) { - aggregate { - max { - assigned_role - } - } - } - is_simple_mode - projects_current_editor_user_id { - name - id - } - } - }` + query := projectsQuery // Create GraphQL request graphqlReq := GraphQLRequest{ diff --git a/registries.go b/registries.go index 5eaf495..c0eef88 100644 --- a/registries.go +++ b/registries.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "time" ) @@ -256,6 +257,282 @@ type saveStatusResponse struct { } `json:"result"` } +// RegistryPermission represents a single user or group permission entry for a registry. +type RegistryPermission struct { + User *string `json:"user"` + Realm *string `json:"realm"` + Group *string `json:"group"` + Privilege string `json:"privilege"` +} + +func resolveRegistryUUID(server, idToken, nameOrUUID string) (string, error) { + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/registries/descriptions", server), idToken) + if err != nil { + return "", err + } + var registries []Registry + if err := json.Unmarshal(body, ®istries); err != nil { + return "", fmt.Errorf("failed to parse registries: %w", err) + } + for _, r := range registries { + if r.UUID == nameOrUUID || r.Name == nameOrUUID { + return r.UUID, nil + } + } + return "", fmt.Errorf("registry %q not found", nameOrUUID) +} + +func getRegistryPermissions(server, idToken, uuid string) ([]RegistryPermission, error) { + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/sharing", server, uuid), idToken) + if err != nil { + return nil, err + } + var perms []RegistryPermission + if err := json.Unmarshal(body, &perms); err != nil { + return nil, fmt.Errorf("failed to parse permissions: %w", err) + } + return perms, nil +} + +func putRegistryPermissions(server, idToken, uuid string, perms []RegistryPermission) error { + data, err := json.Marshal(perms) + if err != nil { + return fmt.Errorf("failed to marshal permissions: %w", err) + } + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/sharing", server, uuid), bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", idToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + } + return nil +} + +func listRegistryPermissions(server, name string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + if len(perms) == 0 { + fmt.Println("No permissions set (registry is accessible to all users)") + return nil + } + fmt.Printf("%-30s %-8s %s\n", "User/Group", "Type", "Privilege") + fmt.Printf("%s\n", strings.Repeat("-", 52)) + for _, p := range perms { + subject, kind := "", "" + if p.User != nil { + subject, kind = *p.User, "user" + } else if p.Group != nil { + subject, kind = *p.Group, "group" + } + fmt.Printf("%-30s %-8s %s\n", subject, kind, p.Privilege) + } + return nil +} + +func setRegistryPermission(server, name, user, group, privilege string) error { + if privilege != "download" && privilege != "register" { + return fmt.Errorf("privilege must be 'download' or 'register'") + } + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + found := false + for i, p := range perms { + if user != "" && p.User != nil && *p.User == user { + perms[i].Privilege = privilege + found = true + break + } + if group != "" && p.Group != nil && *p.Group == group { + perms[i].Privilege = privilege + found = true + break + } + } + if !found { + newPerm := RegistryPermission{Privilege: privilege} + if user != "" { + newPerm.User = &user + } else { + realm := "site" + newPerm.Group = &group + newPerm.Realm = &realm + } + perms = append(perms, newPerm) + } + if err := putRegistryPermissions(server, token.IDToken, uuid, perms); err != nil { + return err + } + subject := user + if group != "" { + subject = group + } + action := "updated" + if !found { + action = "added" + } + fmt.Printf("Permission %s: %s now has '%s' access\n", action, subject, privilege) + return nil +} + +func removeRegistryPermission(server, name, user, group string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + uuid, err := resolveRegistryUUID(server, token.IDToken, name) + if err != nil { + return err + } + perms, err := getRegistryPermissions(server, token.IDToken, uuid) + if err != nil { + return err + } + original := len(perms) + filtered := perms[:0] + for _, p := range perms { + keep := true + if user != "" && p.User != nil && *p.User == user { + keep = false + } + if group != "" && p.Group != nil && *p.Group == group { + keep = false + } + if keep { + filtered = append(filtered, p) + } + } + if len(filtered) == original { + subject := user + if group != "" { + subject = group + } + return fmt.Errorf("%q has no permission on this registry", subject) + } + if err := putRegistryPermissions(server, token.IDToken, uuid, filtered); err != nil { + return err + } + subject := user + if group != "" { + subject = group + } + fmt.Printf("Permission removed: %s\n", subject) + return nil +} + +func getRegistrator(server, name string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + body, err := apiGet(fmt.Sprintf("https://%s/api/v1/registry/config/registrator/%s", server, name), token.IDToken) + if err != nil { + return err + } + + var pretty bytes.Buffer + if err := json.Indent(&pretty, body, "", " "); err != nil { + fmt.Println(string(body)) + return nil + } + fmt.Println(pretty.String()) + return nil +} + +func setRegistrator(server, name, filePath string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + var data []byte + if filePath != "" { + data, err = os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %q: %w", filePath, err) + } + } else { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + return fmt.Errorf("no JSON payload provided — pipe JSON via stdin or use --file") + } + data, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + } + + var payload map[string]interface{} + if err := json.Unmarshal(data, &payload); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + + if enabled, _ := payload["enabled"].(bool); enabled { + if email, _ := payload["email"].(string); email == "" { + return fmt.Errorf("\"email\" is required when registrator is enabled") + } + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registrator/%s", server, name) + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest("POST", apiURL, bytes.NewReader(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed (status %d): %s", resp.StatusCode, string(body)) + } + + fmt.Println("Registrator updated successfully") + return nil +} + func pollRegistrySaveStatus(server, idToken, registryName, operation string) error { apiURL := fmt.Sprintf("https://%s/api/v1/registry/config/registry/%s/savestatus", server, registryName) client := &http.Client{Timeout: 30 * time.Second} diff --git a/user.go b/user.go index 03b2e23..8568bfb 100644 --- a/user.go +++ b/user.go @@ -2,6 +2,7 @@ package main import ( "bytes" + _ "embed" "encoding/json" "fmt" "io" @@ -9,6 +10,15 @@ import ( "time" ) +//go:embed userinfo.gql +var userinfoQuery string + +//go:embed users.gql +var usersQuery string + +//go:embed groups.gql +var groupsQuery string + type UserInfo struct { ID int64 `json:"id"` Name string `json:"name"` @@ -66,34 +76,7 @@ func getUserInfo(server string) (*UserInfo, error) { return nil, fmt.Errorf("authentication required: %w", err) } - // GraphQL query from userinfo.gql - query := `query UserInfo { - users(limit: 1) { - id - name - firstname - emails { - email - } - groups: user_groups { - id: group_id - group { - name - group_id - } - } - username - roles { - role { - description - id - name - } - } - accepted_tos - survey_submitted_time - } -}` + query := userinfoQuery // Create GraphQL request graphqlReq := UserInfoRequest{ @@ -213,6 +196,186 @@ type ManageUsersResponse struct { Features json.RawMessage `json:"features"` } +type GroupsGQLResponse struct { + Data struct { + Groups []struct { + Name string `json:"name"` + GroupID int64 `json:"group_id"` + } `json:"groups"` + Products []struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + ID int64 `json:"id"` + ComputeTypeName string `json:"compute_type_name"` + } `json:"products"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type AdminGroup struct { + Name string `json:"name"` + ID int64 `json:"id"` +} + +func listGroups(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + url := fmt.Sprintf("https://%s/app/config/groups", server) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch groups: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request failed (status %d): %s", resp.StatusCode, string(body)) + } + + var groups []AdminGroup + if err := json.Unmarshal(body, &groups); err != nil { + return fmt.Errorf("failed to parse groups: %w", err) + } + if len(groups) == 0 { + fmt.Println("No groups found") + return nil + } + for _, g := range groups { + fmt.Println(g.Name) + } + return nil +} + +func listGroupsGQL(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + gqlReq := UserInfoRequest{ + OperationName: "Groups", + Query: groupsQuery, + Variables: map[string]interface{}{"limit": 500}, + } + jsonData, err := json.Marshal(gqlReq) + if err != nil { + return fmt.Errorf("failed to marshal groups request: %w", err) + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/v1/graphql", server), bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Hasura-Role", "jhuser") + req.Header.Set("X-Juliahub-Ensure-JS", "true") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch groups: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var groupsResp GroupsGQLResponse + if err := json.Unmarshal(body, &groupsResp); err != nil { + return fmt.Errorf("failed to parse groups: %w", err) + } + if len(groupsResp.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", groupsResp.Errors) + } + if len(groupsResp.Data.Groups) == 0 { + fmt.Println("No groups found") + return nil + } + for _, g := range groupsResp.Data.Groups { + fmt.Println(g.Name) + } + return nil +} + +type UsersGQLResponse struct { + Data struct { + Users []struct { + Username string `json:"username"` + ID int64 `json:"id"` + Name *string `json:"name"` + } `json:"users"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func listUsersGQL(server string) error { + token, err := ensureValidToken() + if err != nil { + return fmt.Errorf("authentication required: %w", err) + } + + gqlReq := UserInfoRequest{ + OperationName: "Users", + Query: usersQuery, + Variables: map[string]interface{}{"limit": 500}, + } + jsonData, err := json.Marshal(gqlReq) + if err != nil { + return fmt.Errorf("failed to marshal users request: %w", err) + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/v1/graphql", server), bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.IDToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Hasura-Role", "jhuser") + req.Header.Set("X-Juliahub-Ensure-JS", "true") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch users: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var usersResp UsersGQLResponse + if err := json.Unmarshal(body, &usersResp); err != nil { + return fmt.Errorf("failed to parse users: %w", err) + } + if len(usersResp.Errors) > 0 { + return fmt.Errorf("GraphQL errors: %v", usersResp.Errors) + } + if len(usersResp.Data.Users) == 0 { + fmt.Println("No users found") + return nil + } + for _, u := range usersResp.Data.Users { + name := u.Username + if u.Name != nil && *u.Name != "" { + name = *u.Name + } + fmt.Printf("%s (%s)\n", name, u.Username) + } + return nil +} + func listUsers(server string, verbose bool) error { token, err := ensureValidToken() if err != nil { @@ -260,10 +423,8 @@ func listUsers(server string, verbose bool) error { } } - // Display users - fmt.Printf("Users (%d total):\n\n", len(response.Users)) - if verbose { + fmt.Printf("Users (%d total):\n\n", len(response.Users)) // Verbose mode: show all details for _, user := range response.Users { fmt.Printf("UUID: %s\n", user.UUID) @@ -283,15 +444,12 @@ func listUsers(server string, verbose bool) error { fmt.Println() } } else { - // Default mode: show only Name and Email for _, user := range response.Users { - if user.Name != nil { - fmt.Printf("Name: %s\n", *user.Name) - } else { - fmt.Printf("Name: (not set)\n") + name := user.Email + if user.Name != nil && *user.Name != "" { + name = *user.Name } - fmt.Printf("Email: %s\n", user.Email) - fmt.Println() + fmt.Printf("%s (%s)\n", name, user.Email) } } diff --git a/users.gql b/users.gql new file mode 100644 index 0000000..f10dc6f --- /dev/null +++ b/users.gql @@ -0,0 +1,12 @@ +query Users($name: String = "%", $limit: Int = 100) { + users: public_users( + limit: $limit + where: { + _or: [{ username: { _ilike: $name } }, { name: { _ilike: $name } }] + } + ) { + username + id + name + } +}