Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@

## [Unreleased]

### 2026-03-20
- **Feat**: `admin api-keys create/update` および `me api-keys create` に `--permissions` オプションを追加 — `read`, `write`, `create`, `update`, `delete` を指定して XACML ポリシーを自動生成 (GeonicDB #753)
- **Breaking**: `admin tenants update` から `--anonymous-access` / `--no-anonymous-access` フラグを削除 — テナントフィーチャーフラグが廃止され、匿名アクセスは XACML ポリシーで制御 (GeonicDB #752)
- **Breaking**: スコープヘルプから `write:X implies read:X` の記述を削除 — `write:X` は `read:X` を暗黙的に含まなくなった (GeonicDB #723)
- **Docs**: `admin policies create` のヘルプに `servicePath` リソース属性、priority の説明(大きい数値=高優先度)、デフォルトロールポリシーの説明を追加 (GeonicDB #747, #751, #752)
- **Docs**: README を更新 — `--permissions` オプション、XACML 認可モデル、スコープ包含関係、API キーヘッダ排他仕様を反映
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### 2026-03-19
- **Feat**: `admin tenants update` に `--anonymous-access` / `--no-anonymous-access` オプションを追加 — テナントの匿名アクセス設定を CLI から管理可能に (#76)
- **Docs**: `admin policies create` のヘルプに `target` フィールド(subjects, resources, actions, environments)のスキーマ例を追加 (#75)
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,16 @@ Displays the current authenticated user, token expiry, and active profile.
| `--entity-types <types>` | Allowed entity types (comma-separated) |
| `--rate-limit <n>` | Rate limit (requests per minute) |
| `--dpop-required` | Require DPoP token binding (RFC 9449) |
| `--permissions <perms>` | Permissions for auto-generated XACML policy (comma-separated) |
| `--save` | Save the API key to profile config |

```bash
# Create an API key and save to config
geonic me api-keys create --name my-app --scopes read:entities --save

# Create with permissions (auto-generates XACML policy)
geonic me api-keys create --name my-app --permissions read,write --save

# Create from JSON
geonic me api-keys create '{"name":"my-app","allowedScopes":["read:entities"]}'
```
Expand Down Expand Up @@ -392,6 +396,16 @@ Temporal entityOperations query supports: `--aggr-methods`, `--aggr-period`.
| `admin policies activate <id>` | Activate a policy |
| `admin policies deactivate <id>` | Deactivate a policy |

**XACML Authorization Model**: All authorization is unified under XACML policies. Default role policies (priority 0):

| Role | Default Behavior |
|---|---|
| `user` | GET only (read-only) |
| `api_key` | All Deny |
| `anonymous` | All Deny |

Custom policies with higher priority (e.g. 100) override defaults. Target resource attributes include: `path`, `entityType`, `entityId`, `entityOwner`, `tenantService`, `servicePath`. The `servicePath` attribute supports glob patterns (e.g. `/opendata/**`) and regex matching.

#### admin oauth-clients

| Subcommand | Description |
Expand All @@ -412,7 +426,9 @@ Temporal entityOperations query supports: `--aggr-methods`, `--aggr-period`.
| `admin api-keys update <keyId> [json]` | Update an API key |
| `admin api-keys delete <keyId>` | Delete an API key |

`admin api-keys list` supports `--tenant-id` to filter by tenant. `admin api-keys create` supports flag options: `--name`, `--scopes`, `--origins`, `--entity-types`, `--rate-limit`, `--dpop-required`, `--tenant-id`, `--save`. `admin api-keys update` supports `--name`, `--scopes`, `--origins`, `--entity-types`, `--rate-limit`, `--dpop-required` / `--no-dpop-required`.
`admin api-keys list` supports `--tenant-id` to filter by tenant. `admin api-keys create` supports flag options: `--name`, `--scopes`, `--origins`, `--entity-types`, `--rate-limit`, `--dpop-required`, `--permissions`, `--tenant-id`, `--save`. `admin api-keys update` supports `--name`, `--scopes`, `--origins`, `--entity-types`, `--rate-limit`, `--dpop-required` / `--no-dpop-required`, `--permissions`.

**Permissions**: The `--permissions` flag accepts a comma-separated list of `read`, `write`, `create`, `update`, `delete`. `write` is an alias for `create` + `update` + `delete`. When specified, XACML policies are auto-generated for the API key (respects `allowedEntityTypes`).

**Note**: `allowedOrigins` must contain at least 1 item when specified. Use `*` to allow all origins. `allowedEntityTypes` is enforced at runtime — API key holders can only access entities of the specified types. `admin api-keys list` / `admin api-keys get` output includes a `dpopRequired` field (boolean).

Expand Down Expand Up @@ -560,13 +576,13 @@ geonic entities list --api-key gdb_your_api_key_here
GDB_API_KEY=gdb_your_api_key_here geonic entities list
```

When both a Bearer token and an API key are configured, both headers are sent (the server determines precedence).
When both a Bearer token and an API key are configured, headers are sent exclusively — the API key takes precedence when present.

### Valid Scopes

`read:entities`, `write:entities`, `read:subscriptions`, `write:subscriptions`, `read:registrations`, `write:registrations`, `read:rules`, `write:rules`, `read:custom-data-models`, `write:custom-data-models`, `admin:users`, `admin:tenants`, `admin:policies`, `admin:oauth-clients`, `admin:api-keys`, `admin:metrics`

`write:X` implies `read:X`. `admin:X` implies both `read:X` and `write:X`.
`admin:X` implies both `read:X` and `write:X`. `write:X` does **not** imply `read:X` — specify both explicitly if needed.

Special scopes: `permanent` (no token expiry), `jwt` (JWT format token).

Expand Down
21 changes: 18 additions & 3 deletions src/commands/admin/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function buildBodyFromFlags(opts: Record<string, unknown>): Record<string, unkno
payload.rateLimit = { perMinute };
}
if (opts.dpopRequired !== undefined) payload.dpopRequired = opts.dpopRequired;
if (opts.permissions) payload.permissions = (opts.permissions as string).split(",").map((s: string) => s.trim()).filter(Boolean);
if (opts.tenantId) payload.tenantId = opts.tenantId;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return payload;
}
Expand Down Expand Up @@ -117,6 +118,7 @@ export function registerApiKeysCommand(parent: Command): void {
.option("--entity-types <types>", "Comma-separated entity types")
.option("--rate-limit <n>", "Rate limit per minute")
.option("--dpop-required", "Require DPoP token binding")
.option("--permissions <perms>", "Comma-separated permissions (read, write, create, update, delete)")
.option("--tenant-id <id>", "Tenant ID")
.option("--save", "Save the API key to profile config")
.action(
Expand All @@ -128,6 +130,7 @@ export function registerApiKeysCommand(parent: Command): void {
entityTypes?: string;
rateLimit?: string;
dpopRequired?: boolean;
permissions?: string;
tenantId?: string;
save?: boolean;
};
Expand All @@ -137,7 +140,7 @@ export function registerApiKeysCommand(parent: Command): void {
let body: unknown;
if (json) {
body = await parseJsonInput(json as string | undefined);
} else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== undefined || opts.tenantId) {
} else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== undefined || opts.permissions || opts.tenantId) {
body = buildBodyFromFlags(opts);
} else {
body = await parseJsonInput();
Expand Down Expand Up @@ -174,13 +177,23 @@ export function registerApiKeysCommand(parent: Command): void {
}),
);

addNotes(create, API_KEY_SCOPES_HELP_NOTES);
addNotes(create, [
...API_KEY_SCOPES_HELP_NOTES,
"",
"Valid permissions: read, write, create, update, delete",
" write = create + update + delete",
" Permissions auto-generate XACML policies (allowedEntityTypes respected).",
]);

addExamples(create, [
{
description: "Create an API key with flags",
command: "geonic admin api-keys create --name my-key --scopes read:entities,write:entities --origins '*'",
},
{
description: "Create with permissions (auto-generates XACML policy)",
command: "geonic admin api-keys create --name my-key --permissions read,write --origins '*'",
},
{
description: "Create an API key with DPoP required",
command: "geonic admin api-keys create --name my-key --dpop-required",
Expand All @@ -202,6 +215,7 @@ export function registerApiKeysCommand(parent: Command): void {
.option("--rate-limit <n>", "Rate limit per minute")
.option("--dpop-required", "Require DPoP token binding")
.option("--no-dpop-required", "Disable DPoP token binding")
.option("--permissions <perms>", "Comma-separated permissions (read, write, create, update, delete)")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.action(
withErrorHandler(
async (keyId: unknown, json: unknown, _opts: unknown, cmd: Command) => {
Expand All @@ -212,14 +226,15 @@ export function registerApiKeysCommand(parent: Command): void {
entityTypes?: string;
rateLimit?: string;
dpopRequired?: boolean;
permissions?: string;
};

validateOrigins(undefined, opts);

let body: unknown;
if (json) {
body = await parseJsonInput(json as string | undefined);
} else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== undefined) {
} else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== undefined || opts.permissions) {
body = buildBodyFromFlags(opts);
} else {
body = await parseJsonInput();
Expand Down
12 changes: 10 additions & 2 deletions src/commands/admin/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,14 @@ export function registerPoliciesCommand(parent: Command): void {
" }\n\n" +
"Target fields:\n" +
" subjects — attributeId: role, userId, email, tenantId\n" +
" resources — attributeId: path, entityType, entityId, entityOwner, tenantService\n" +
" resources — attributeId: path, entityType, entityId, entityOwner, tenantService, servicePath\n" +
" actions — attributeId: method (GET, POST, PATCH, DELETE)\n\n" +
"Each element: {attributeId, matchValue, matchFunction?}\n" +
" matchFunction: \"string-equal\" (default) | \"string-regexp\" | \"glob\"",
" matchFunction: \"string-equal\" (default) | \"string-regexp\" | \"glob\"\n\n" +
"Priority: higher number = higher priority (default: 0).\n" +
" Custom policies (e.g. 100) override default role policies (0).\n\n" +
"Default role policies (priority 0):\n" +
" user → GET only, api_key → all Deny, anonymous → all Deny",
)
.action(
withErrorHandler(async (json: unknown, _opts: unknown, cmd: Command) => {
Expand Down Expand Up @@ -105,6 +109,10 @@ export function registerPoliciesCommand(parent: Command): void {
description: "Create anonymous access policy",
command: `geonic admin policies create '{"policyId":"public-read","target":{"subjects":[{"attributeId":"role","matchValue":"anonymous"}],"resources":[{"attributeId":"entityType","matchValue":"WeatherObserved"}],"actions":[{"attributeId":"method","matchValue":"GET"}]},"rules":[{"effect":"Permit"}]}'`,
},
{
description: "Create servicePath-based policy (glob match)",
command: `geonic admin policies create '{"description":"Allow read on /opendata/**","priority":100,"target":{"resources":[{"attributeId":"servicePath","matchValue":"/opendata/**","matchFunction":"glob"}],"actions":[{"attributeId":"method","matchValue":"GET"}]},"rules":[{"effect":"Permit"}]}'`,
},
{
description: "Create from a JSON file",
command: "geonic admin policies create @policy.json",
Expand Down
26 changes: 2 additions & 24 deletions src/commands/admin/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,24 +107,10 @@ export function registerTenantsCommand(parent: Command): void {
"JSON payload: only specified fields are updated.\n" +
' e.g. {"name": "new-name", "description": "Updated description"}',
)
.option("--anonymous-access", "Enable anonymous access for the tenant")
.option("--no-anonymous-access", "Disable anonymous access for the tenant")
.action(
withErrorHandler(
async (id: unknown, json: unknown, opts: unknown, cmd: Command) => {
const options = opts as { anonymousAccess?: boolean };
let body: Record<string, unknown>;
if (json) {
body = (await parseJsonInput(json as string)) as Record<string, unknown>;
} else if (options.anonymousAccess !== undefined) {
body = {
settings: {
features: { anonymousAccessEnabled: options.anonymousAccess },
},
};
} else {
body = (await parseJsonInput(undefined)) as Record<string, unknown>;
}
async (id: unknown, json: unknown, _opts: unknown, cmd: Command) => {
const body = (await parseJsonInput(json as string | undefined)) as Record<string, unknown>;
const client = createClient(cmd);
const format = getFormat(cmd);
const response = await client.rawRequest(
Expand All @@ -147,14 +133,6 @@ export function registerTenantsCommand(parent: Command): void {
description: "Rename a tenant",
command: `geonic admin tenants update <tenant-id> '{"name":"new-name"}'`,
},
{
description: "Enable anonymous access",
command: "geonic admin tenants update <tenant-id> --anonymous-access",
},
{
description: "Disable anonymous access",
command: "geonic admin tenants update <tenant-id> --no-anonymous-access",
},
{
description: "Update from a JSON file",
command: "geonic admin tenants update <tenant-id> @patch.json",
Expand Down
17 changes: 15 additions & 2 deletions src/commands/me-api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function addMeApiKeysSubcommand(me: Command): void {
.option("--entity-types <types>", "Allowed entity types (comma-separated)")
.option("--rate-limit <n>", "Rate limit per minute")
.option("--dpop-required", "Require DPoP token binding")
.option("--permissions <perms>", "Comma-separated permissions (read, write, create, update, delete)")
.option("--save", "Save the API key to config for automatic use")
.action(
withErrorHandler(async (json: unknown, _opts: unknown, cmd: Command) => {
Expand All @@ -51,6 +52,7 @@ export function addMeApiKeysSubcommand(me: Command): void {
entityTypes?: string;
rateLimit?: string;
dpopRequired?: boolean;
permissions?: string;
save?: boolean;
};

Expand All @@ -66,13 +68,14 @@ export function addMeApiKeysSubcommand(me: Command): void {
let body: unknown;
if (json) {
body = await parseJsonInput(json as string | undefined);
} else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== undefined) {
} else if (opts.name || opts.scopes || opts.origins || opts.entityTypes || opts.rateLimit || opts.dpopRequired !== undefined || opts.permissions) {
const payload: Record<string, unknown> = {};
if (opts.name) payload.name = opts.name;
if (opts.scopes) payload.allowedScopes = opts.scopes.split(",").map((s: string) => s.trim()).filter(Boolean);
if (opts.origins) payload.allowedOrigins = opts.origins.split(",").map((s: string) => s.trim()).filter(Boolean);
if (opts.entityTypes) payload.allowedEntityTypes = opts.entityTypes.split(",").map((s: string) => s.trim()).filter(Boolean);
if (opts.dpopRequired !== undefined) payload.dpopRequired = opts.dpopRequired;
if (opts.permissions) payload.permissions = opts.permissions.split(",").map((s: string) => s.trim()).filter(Boolean);
if (opts.rateLimit) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const raw = opts.rateLimit.trim();
if (!/^\d+$/.test(raw)) {
Expand Down Expand Up @@ -128,13 +131,23 @@ export function addMeApiKeysSubcommand(me: Command): void {
}),
);

addNotes(create, API_KEY_SCOPES_HELP_NOTES);
addNotes(create, [
...API_KEY_SCOPES_HELP_NOTES,
"",
"Valid permissions: read, write, create, update, delete",
" write = create + update + delete",
" Permissions auto-generate XACML policies (allowedEntityTypes respected).",
]);

addExamples(create, [
{
description: "Create an API key with flags",
command: "geonic me api-keys create --name my-app --scopes read:entities --origins 'https://example.com'",
},
{
description: "Create with permissions (auto-generates XACML policy)",
command: "geonic me api-keys create --name my-app --permissions read,write --save",
},
{
description: "Create and save API key to config",
command: "geonic me api-keys create --name my-app --save",
Expand Down
3 changes: 2 additions & 1 deletion src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const SCOPES_HELP_NOTES = [
" admin:users, admin:tenants, admin:policies, admin:oauth-clients,",
" admin:api-keys, admin:metrics",
"",
"write:X implies read:X. admin:X implies both read:X and write:X.",
"admin:X implies both read:X and write:X.",
"write:X does NOT imply read:X — specify both if needed.",
];

/**
Expand Down
66 changes: 66 additions & 0 deletions tests/admin-api-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,52 @@ describe("admin api-keys commands", () => {
expect(client.rawRequest).toHaveBeenCalledWith("POST", "/admin/api-keys", { body });
});

it("includes permissions when --permissions flag is set", async () => {
const isTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
try {
client.rawRequest.mockResolvedValue(
mockResponse({ keyId: "k-perms", key: "gdb_perms" }, 201),
);
const program = makeProgram();
await runCommand(program, [
"admin", "api-keys", "create",
"--name", "perms-key",
"--permissions", "read,write",
]);
expect(client.rawRequest).toHaveBeenCalledWith("POST", "/admin/api-keys", {
body: {
name: "perms-key",
permissions: ["read", "write"],
},
});
} finally {
process.stdin.isTTY = isTTY;
}
});

it("uses flag path with --permissions as the only flag", async () => {
const isTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
try {
client.rawRequest.mockResolvedValue(
mockResponse({ keyId: "k-perms2", key: "gdb_perms2" }, 201),
);
const program = makeProgram();
await runCommand(program, [
"admin", "api-keys", "create",
"--permissions", "read",
]);
expect(client.rawRequest).toHaveBeenCalledWith("POST", "/admin/api-keys", {
body: {
permissions: ["read"],
},
});
} finally {
process.stdin.isTTY = isTTY;
}
});

it("accepts valid allowedOrigins in JSON body", async () => {
const body = { name: "valid-key", allowedOrigins: ["https://example.com"] };
vi.mocked(parseJsonInput).mockResolvedValue(body);
Expand Down Expand Up @@ -412,6 +458,26 @@ describe("admin api-keys commands", () => {
}
});

it("includes permissions when --permissions flag is set", async () => {
const isTTY = process.stdin.isTTY;
process.stdin.isTTY = true;
try {
client.rawRequest.mockResolvedValue(mockResponse({ keyId: "k1" }));
const program = makeProgram();
await runCommand(program, [
"admin", "api-keys", "update", "k1",
"--permissions", "read,create,delete",
]);
expect(client.rawRequest).toHaveBeenCalledWith("PATCH", "/admin/api-keys/k1", {
body: {
permissions: ["read", "create", "delete"],
},
});
} finally {
process.stdin.isTTY = isTTY;
}
});

it("reads from stdin when no json arg and no flags", async () => {
const body = { name: "stdin-update" };
vi.mocked(parseJsonInput).mockResolvedValue(body);
Expand Down
Loading
Loading