Skip to content

Commit f0d4669

Browse files
sokolivaishymko
andauthored
feat(server): implement Resource Scoping for tasks and push notifications (#709)
## Description Introduces caller identity isolation to ensure clients only access authorized resources, as mandated by the [A2A spec](https://a2a-protocol.org/latest/specification/#131-data-access-and-authorization-scoping). - Add `owner`field to `TaskMixin` and `PushNotificationConfig` database models. - Add `last_updated` field to `TaskMixin` for optimized sorting and indexing. - Update `DatabaseTaskStore`, `InMemoryTaskStore`, `DatabasePushNotificationConfigStore` and `InMemoryPushNotificationConfigStore` to use `OwnerResolver`. - Add Resource Scoping related Unit tests. - Add Alembic configuration to enable users to update their own databases with non-optional `owner` column in `tasks` and `push_notification_configs` table and optional `last_updated` and index `(owner, last_updated)` in `tasks` . - Distribute alembic configuration, enable CLI commands such as `uv run a2a-db` for database updating. ## Note - In `src/a2a/server/tasks/database_task_store.py` `list` method, Gemini suggested a refactor of pagination. I thoroughly reviewed it and confirmed that the logic is the same and that readability of code improved so I decided to accept it. - It seems there was a functional bug in [InMemoryPushNotificationConfigStore](https://github.com/a2aproject/a2a-python/blob/main/src/a2a/server/tasks/inmemory_push_notification_config_store.py) `delete_info` method. When `config_id` is None and only `task_id` was provided it would search for configs mapped to `task_id` with `config.id=task_id`, contrary to `delete_info` method of [DatabasePushNotificationConfigStore](https://github.com/a2aproject/a2a-python/blob/main/src/a2a/server/tasks/database_push_notification_config_store.py) where if config_id is None, all configurations for the task are deleted. Unfortunately, I did not find intended behavior defined in the spec, but behavior of `DatabasePushNotificationConfigStore's` `delete_info` seems more logical. ## Breaking changes - added non-optional owner field to the Task Model. Use alembic configuration to update your database. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) Fixes #610 🦕 --------- Co-authored-by: Ivan Shymko <ishymko@google.com>
1 parent 57cb529 commit f0d4669

32 files changed

Lines changed: 2277 additions & 273 deletions

.github/actions/spelling/allow.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ openapiv2
9292
opensource
9393
otherurl
9494
pb2
95+
poolclass
9596
postgres
9697
POSTGRES
9798
postgresql

pyproject.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ postgresql = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.0"]
3939
mysql = ["sqlalchemy[asyncio,aiomysql]>=2.0.0"]
4040
signing = ["PyJWT>=2.0.0"]
4141
sqlite = ["sqlalchemy[asyncio,aiosqlite]>=2.0.0"]
42+
db-cli = ["alembic>=1.14.0"]
4243

4344
sql = ["a2a-sdk[postgresql,mysql,sqlite]"]
4445

@@ -49,6 +50,7 @@ all = [
4950
"a2a-sdk[grpc]",
5051
"a2a-sdk[telemetry]",
5152
"a2a-sdk[signing]",
53+
"a2a-sdk[db-cli]",
5254
]
5355

5456
[project.urls]
@@ -347,3 +349,17 @@ docstring-code-format = true
347349
docstring-code-line-length = "dynamic"
348350
quote-style = "single"
349351
indent-style = "space"
352+
353+
354+
[tool.alembic]
355+
356+
# path to migration scripts.
357+
script_location = "src/a2a/migrations"
358+
359+
# additional paths to be prepended to sys.path. defaults to the current working directory.
360+
prepend_sys_path = [
361+
"src"
362+
]
363+
364+
[project.scripts]
365+
a2a-db = "a2a.a2a_db_cli:run_migrations"

src/a2a/a2a_db_cli.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import argparse
2+
import logging
3+
import os
4+
5+
from importlib.resources import files
6+
7+
8+
try:
9+
from alembic import command
10+
from alembic.config import Config
11+
12+
except ImportError as e:
13+
raise ImportError(
14+
"CLI requires Alembic. Install with: 'pip install a2a-sdk[db-cli]'."
15+
) from e
16+
17+
18+
def _add_shared_args(
19+
parser: argparse.ArgumentParser, is_sub: bool = False
20+
) -> None:
21+
"""Add common arguments to the given parser."""
22+
prefix = 'sub_' if is_sub else ''
23+
parser.add_argument(
24+
'--database-url',
25+
dest=f'{prefix}database_url',
26+
help='Database URL to use for the migrations. If not set, the DATABASE_URL environment variable will be used.',
27+
)
28+
parser.add_argument(
29+
'--tasks-table',
30+
dest=f'{prefix}tasks_table',
31+
help='Custom tasks table to update. If not set, the default is "tasks".',
32+
)
33+
parser.add_argument(
34+
'--push-notification-configs-table',
35+
dest=f'{prefix}push_notification_configs_table',
36+
help='Custom push notification configs table to update. If not set, the default is "push_notification_configs".',
37+
)
38+
parser.add_argument(
39+
'-v',
40+
'--verbose',
41+
dest=f'{prefix}verbose',
42+
help='Enable verbose output (sets sqlalchemy.engine logging to INFO)',
43+
action='store_true',
44+
)
45+
parser.add_argument(
46+
'--sql',
47+
dest=f'{prefix}sql',
48+
help='Run migrations in sql mode (generate SQL instead of executing)',
49+
action='store_true',
50+
)
51+
52+
53+
def create_parser() -> argparse.ArgumentParser:
54+
"""Create the argument parser for the migration tool."""
55+
parser = argparse.ArgumentParser(description='A2A Database Migration Tool')
56+
57+
# Global options
58+
parser.add_argument(
59+
'--add_columns_owner_last_updated-default-owner',
60+
dest='owner',
61+
help="Value for the 'owner' column (used in specific migrations). If not set defaults to 'unknown'",
62+
)
63+
_add_shared_args(parser)
64+
65+
subparsers = parser.add_subparsers(dest='cmd', help='Migration command')
66+
67+
# Upgrade command
68+
up_parser = subparsers.add_parser(
69+
'upgrade', help='Upgrade to a later version'
70+
)
71+
up_parser.add_argument(
72+
'revision',
73+
nargs='?',
74+
default='head',
75+
help='Revision target (default: head)',
76+
)
77+
up_parser.add_argument(
78+
'--add_columns_owner_last_updated-default-owner',
79+
dest='sub_owner',
80+
help="Value for the 'owner' column (used in specific migrations). If not set defaults to 'legacy_v03_no_user_info'",
81+
)
82+
_add_shared_args(up_parser, is_sub=True)
83+
84+
# Downgrade command
85+
down_parser = subparsers.add_parser(
86+
'downgrade', help='Revert to a previous version'
87+
)
88+
down_parser.add_argument(
89+
'revision',
90+
nargs='?',
91+
default='base',
92+
help='Revision target (e.g., -1, base or a specific ID)',
93+
)
94+
_add_shared_args(down_parser, is_sub=True)
95+
96+
return parser
97+
98+
99+
def run_migrations() -> None:
100+
"""CLI tool to manage database migrations."""
101+
# Configure logging to show INFO messages
102+
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
103+
104+
parser = create_parser()
105+
args = parser.parse_args()
106+
107+
# Default to upgrade head if no command is provided
108+
if not args.cmd:
109+
args.cmd = 'upgrade'
110+
args.revision = 'head'
111+
112+
# Locate the bundled alembic.ini
113+
ini_path = files('a2a').joinpath('alembic.ini')
114+
cfg = Config(str(ini_path))
115+
116+
# Dynamically set the script location
117+
migrations_path = files('a2a').joinpath('migrations')
118+
cfg.set_main_option('script_location', str(migrations_path))
119+
120+
# Consolidate owner, db_url, tables, verbose and sql values
121+
owner = args.owner or getattr(args, 'sub_owner', None)
122+
db_url = args.database_url or getattr(args, 'sub_database_url', None)
123+
task_table = args.tasks_table or getattr(args, 'sub_tasks_table', None)
124+
push_notification_configs_table = (
125+
args.push_notification_configs_table
126+
or getattr(args, 'sub_push_notification_configs_table', None)
127+
)
128+
129+
verbose = args.verbose or getattr(args, 'sub_verbose', False)
130+
sql = args.sql or getattr(args, 'sub_sql', False)
131+
132+
# Pass custom arguments to the migration context
133+
if owner:
134+
cfg.set_main_option(
135+
'add_columns_owner_last_updated_default_owner', owner
136+
)
137+
if db_url:
138+
os.environ['DATABASE_URL'] = db_url
139+
if task_table:
140+
cfg.set_main_option('tasks_table', task_table)
141+
if push_notification_configs_table:
142+
cfg.set_main_option(
143+
'push_notification_configs_table', push_notification_configs_table
144+
)
145+
if verbose:
146+
cfg.set_main_option('verbose', 'true')
147+
148+
# Execute the requested command
149+
if args.cmd == 'upgrade':
150+
logging.info('Upgrading database to %s', args.revision)
151+
command.upgrade(cfg, args.revision, sql=sql)
152+
elif args.cmd == 'downgrade':
153+
logging.info('Downgrading database to %s', args.revision)
154+
command.downgrade(cfg, args.revision, sql=sql)
155+
156+
logging.info('Done.')

src/a2a/alembic.ini

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# A generic, single database configuration.
2+
3+
[loggers]
4+
keys = root,sqlalchemy,alembic
5+
6+
[handlers]
7+
keys = console
8+
9+
[formatters]
10+
keys = generic
11+
12+
[logger_root]
13+
level = INFO
14+
handlers = console
15+
qualname =
16+
17+
[logger_sqlalchemy]
18+
level = WARNING
19+
handlers =
20+
qualname = sqlalchemy.engine
21+
22+
[logger_alembic]
23+
level = WARNING
24+
handlers =
25+
qualname = alembic
26+
27+
[handler_console]
28+
class = StreamHandler
29+
args = (sys.stderr,)
30+
level = NOTSET
31+
formatter = generic
32+
33+
[formatter_generic]
34+
format = %(levelname)-5.5s [%(name)s] %(message)s
35+
datefmt = %H:%M:%S

src/a2a/migrations/README.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# A2A SDK Database Migrations
2+
3+
This directory handles the database schema updates for the A2A SDK. It uses a bundled CLI tool to simplify the migration process.
4+
5+
## Installation
6+
7+
To use the `a2a-db` migration tool, install the `a2a-sdk` with the `db-cli` extra.
8+
9+
| Extra | `uv` Command | `pip` Command |
10+
| :--- | :--- | :--- |
11+
| **CLI Only** | `uv add "a2a-sdk[db-cli]"` | `pip install "a2a-sdk[db-cli]"` |
12+
| **All Extras** | `uv add "a2a-sdk[all]"` | `pip install "a2a-sdk[all]"` |
13+
14+
15+
## User Guide for Integrators
16+
17+
When you install the `a2a-sdk`, you get a built-in command `a2a-db` which updates your database to make it compatible with the latest version of the SDK.
18+
19+
### 1. Recommended: Back up your database
20+
21+
Before running migrations, it is recommended to back up your database.
22+
23+
### 2. Set your Database URL
24+
Migrations require the `DATABASE_URL` environment variable to be set with an `async-compatible` driver.
25+
You can set it globally with `export DATABASE_URL`. Examples for SQLite, PostgreSQL and MySQL, respectively:
26+
27+
```bash
28+
export DATABASE_URL="sqlite+aiosqlite://user:pass@host:port/your_database_name"
29+
30+
export DATABASE_URL="postgresql+asyncpg://user:pass@localhost/your_database_name"
31+
32+
export DATABASE_URL="mysql+aiomysql://user:pass@localhost/your_database_name"
33+
```
34+
35+
Or you can use the `--database-url` flag to specify the database URL for a single command.
36+
37+
38+
### 3. Apply Migrations
39+
Always run this command after installing or upgrading the SDK to ensure your database matches the required schema. This will upgrade the tables `tasks` and `push_notification_configs` in your database by adding columns `owner` and `last_updated` and an index `(owner, last_updated)` to the `tasks` table and a column `owner` to the `push_notification_configs` table.
40+
41+
```bash
42+
uv run a2a-db
43+
```
44+
45+
### 4. Customizing Defaults with Flags
46+
#### --add_columns_owner_last_updated-default-owner
47+
Allows you to pass custom values for the new `owner` column. If not set, it will default to the value `legacy_v03_no_user_info`.
48+
49+
```bash
50+
uv run a2a-db --add_columns_owner_last_updated-default-owner "admin_user"
51+
```
52+
#### --database-url
53+
You can use the `--database-url` flag to specify the database URL for a single command.
54+
55+
```bash
56+
uv run a2a-db --database-url "sqlite+aiosqlite:///my_database.db"
57+
```
58+
#### --tasks-table / --push-notification-configs-table
59+
Custom tasks and push notification configs tables to update. If not set, the default are `tasks` and `push_notification_configs`.
60+
61+
```bash
62+
uv run a2a-db --tasks-table "my_tasks" --push-notification-configs-table "my_configs"
63+
```
64+
#### -v / --verbose
65+
Enables verbose output by setting `sqlalchemy.engine` logging to `INFO`.
66+
67+
```bash
68+
uv run a2a-db -v
69+
```
70+
#### --sql
71+
Enables running migrations in `offline` mode. Migrations are generated as SQL scripts and printed to stdout instead of being run against the database.
72+
73+
```bash
74+
uv run a2a-db --sql
75+
```
76+
77+
### 5. Rolling Back
78+
If you need to revert a change:
79+
80+
```bash
81+
# Step back one version
82+
uv run a2a-db downgrade -1
83+
84+
# Downgrade to a specific revision ID
85+
uv run a2a-db downgrade <revision_id>
86+
87+
# Revert all migrations
88+
uv run a2a-db downgrade base
89+
90+
# Revert all migrations in offline mode
91+
uv run a2a-db downgrade head:base --sql
92+
```
93+
94+
Note: All flags except `--add_columns_owner_last_updated-default-owner` can be used during rollbacks.
95+
96+
---
97+
98+
## Developer Guide for SDK Contributors
99+
100+
If you are modifying the SDK models and need to generate new migration files, use the following workflow.
101+
102+
### Creating a New Migration
103+
Developers should use the raw `alembic` command locally to generate migrations. Ensure you are in the project root.
104+
105+
```bash
106+
# Detect changes in models.py and generate a script
107+
uv run alembic revision --autogenerate -m "description of changes"
108+
```
109+
110+
### Internal Layout
111+
- `env.py`: Configures the migration engine and applies the mandatory `DATABASE_URL` check.
112+
- `versions/`: Contains the migration history.
113+
- `script.py.mako`: The template for all new migration files.

src/a2a/migrations/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"Alembic database migration package."

0 commit comments

Comments
 (0)