Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
45 changes: 45 additions & 0 deletions .github/evals/static_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,48 @@ def check_version_consistency(repo_root):
return failures


# ---------------------------------------------------------------------------
# CAT-7 auth.py _ALLOWED_SKILLS sync
# ---------------------------------------------------------------------------


def check_allowed_skills_sync(repo_root, all_skill_names):
"""
EVAL-SKILLS-SYNC-01: _ALLOWED_SKILLS in auth.py must contain every skill
directory name plus "unknown", and no stale entries.
"""
failures = []
auth_path = repo_root / ".github" / "plugins" / "dataverse" / "scripts" / "auth.py"
if not auth_path.exists():
return failures

text = auth_path.read_text(encoding="utf-8")
match = re.search(r"_ALLOWED_SKILLS\s*=\s*frozenset\(\{([^}]+)\}\)", text)
if not match:
failures.append("EVAL-SKILLS-SYNC-01 could not parse _ALLOWED_SKILLS from auth.py")
return failures

allowed = {s.strip().strip('"').strip("'") for s in match.group(1).split(",")}
allowed.discard("")
expected = all_skill_names | {"unknown"}

missing = expected - allowed
extra = allowed - expected

if missing:
failures.append(
f"EVAL-SKILLS-SYNC-01 _ALLOWED_SKILLS is missing skill(s): {sorted(missing)}. "
f"Add them to auth.py."
)
if extra:
failures.append(
f"EVAL-SKILLS-SYNC-01 _ALLOWED_SKILLS has stale entries: {sorted(extra)}. "
f"Remove them from auth.py or add the skill directory."
)

return failures


# ---------------------------------------------------------------------------
# CAT-8 Skill Token Budget (Anthropic Skills spec)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -662,6 +704,9 @@ def main():
repo_root = skills_dir.parent.parent.parent.parent
all_failures.extend(check_version_consistency(repo_root))

# auth.py _ALLOWED_SKILLS sync — check against actual skill directories
all_failures.extend(check_allowed_skills_sync(repo_root, all_skill_names))

if all_failures:
# Group output by category prefix for readability
categories = {}
Expand Down
4 changes: 2 additions & 2 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "dataverse-skills",
"metadata": {
"description": "Official Dataverse plugin marketplace for agent-guided development",
"version": "1.4.5"
"version": "1.5.0"
},
"owner": {
"name": "Microsoft",
Expand All @@ -13,7 +13,7 @@
"name": "dataverse",
"description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.",
"source": "./.github/plugins/dataverse",
"version": "1.4.5",
"version": "1.5.0",
"homepage": "https://github.com/microsoft/Dataverse-skills"
}
]
Expand Down
2 changes: 1 addition & 1 deletion .github/plugins/dataverse/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "dataverse",
"description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.",
"version": "1.4.5",
"version": "1.5.0",
"author": {
"name": "Microsoft",
"url": "https://www.microsoft.com"
Expand Down
2 changes: 1 addition & 1 deletion .github/plugins/dataverse/.github/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "dataverse",
"description": "Agent skills for building on, analyzing, and managing Microsoft Dataverse — with Dataverse MCP, PAC CLI, and Python SDK.",
"version": "1.4.5",
"version": "1.5.0",
"author": {
"name": "Microsoft",
"url": "https://www.microsoft.com"
Expand Down
127 changes: 116 additions & 11 deletions .github/plugins/dataverse/scripts/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,18 @@

Functions:
load_env() — loads .env into os.environ
get_credential() — returns a TokenCredential for use with DataverseClient
get_client(skill) — returns a DataverseClient with plugin attribution
get_token(scope=None) — returns a raw access token string
get_plugin_headers(skill, token) — returns headers dict for raw Web API calls

Usage:
# PREFERRED — use the Python SDK for all supported operations:
from auth import get_credential, load_env
from PowerPlatform.Dataverse.client import DataverseClient
load_env()
client = DataverseClient(os.environ["DATAVERSE_URL"], get_credential())
# PREFERRED — SDK with plugin attribution:
from auth import get_client
client = get_client("dv-data")

# ONLY for operations the SDK does NOT support (forms, views, $ref, $apply):
from auth import get_token, load_env
token = get_token()
# Raw Web API only (forms, views, $ref, $apply):
from auth import get_token, get_plugin_headers
headers = get_plugin_headers("dv-metadata", get_token())

Reads from .env in the repo root (parent of scripts/) or current working directory:
DATAVERSE_URL — required
Expand All @@ -36,6 +35,7 @@
"""

import os
import re
import sys
from pathlib import Path

Expand Down Expand Up @@ -66,7 +66,7 @@ def load_env():
_credential = None


def get_credential():
def _get_credential():
"""
Return an Azure Identity TokenCredential, creating one on first call.

Expand Down Expand Up @@ -165,7 +165,7 @@ def get_token(scope=None):
if not scope:
scope = f"{dataverse_url}/.default"

credential = get_credential()
credential = _get_credential()

try:
from azure.identity import DeviceCodeCredential
Expand All @@ -188,6 +188,111 @@ def get_token(scope=None):
return token.token


_ALLOWED_SKILLS = frozenset({
Comment thread
arorashivam96 marked this conversation as resolved.
"dv-overview", "dv-connect", "dv-data", "dv-query",
"dv-metadata", "dv-solution", "dv-admin", "dv-security",
"unknown",
})
_ALLOWED_AGENTS = frozenset({
"claude-code", "copilot", "cursor", "codex", "unknown",
})
# Strict format: key=value pairs, semicolon-separated. No spaces, no PII.
_CONTEXT_RE = re.compile(
r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$"
)


def _plugin_version():
Comment thread
arorashivam96 marked this conversation as resolved.
"""Read plugin version from .env (set by dv-connect at setup time)."""
return os.environ.get("DATAVERSE_PLUGIN_VERSION", "unknown")
Comment thread
arorashivam96 marked this conversation as resolved.


def _current_agent():
agent = os.environ.get("DATAVERSE_PLUGIN_AGENT", "unknown")
if agent not in _ALLOWED_AGENTS:
raise ValueError(f"Unknown agent '{agent}'; allowed: {_ALLOWED_AGENTS}")
return agent


def _validate_skill(skill):
if skill not in _ALLOWED_SKILLS:
raise ValueError(f"Unknown skill '{skill}'; allowed: {_ALLOWED_SKILLS}")
return skill


def _build_operation_context(skill):
"""Build and validate the operation_context string.

Returns an OperationContext object for the SDK. The string is validated
both here (via allowlists) and inside OperationContext.__post_init__
(via regex + control-char check).

SECURITY: Only closed-schema values from _ALLOWED_SKILLS and
_ALLOWED_AGENTS are used. Never pass user-provided or free-form
strings into operation_context — it is written to HTTP headers and
server-side telemetry logs.
"""
ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}"
if not _CONTEXT_RE.match(ctx_str):
raise ValueError(
f"operation_context failed format validation: {ctx_str!r}. "
"Must be semicolon-separated key=value pairs with no spaces or special characters."
)
from PowerPlatform.Dataverse.core.config import OperationContext
return OperationContext(user_agent_context=ctx_str)


def get_client(skill, **kwargs):
"""Return a DataverseClient with plugin attribution baked in.

The operation_context is appended to the User-Agent header as a
parenthesized comment for server-side traffic attribution.

IMPORTANT: Do not modify the operation_context — it uses a closed
schema (app/skill/agent) for safe server-side attribution. Never
include secrets, PII, or free-form text.

:param skill: Skill name (e.g. "dv-data", "dv-query").
:param kwargs: Extra keyword arguments forwarded to DataverseClient.
:returns: Configured DataverseClient instance.
"""
load_env()
_validate_skill(skill)
from PowerPlatform.Dataverse.client import DataverseClient
return DataverseClient(
base_url=os.environ["DATAVERSE_URL"],
credential=_get_credential(),
context=_build_operation_context(skill),
**kwargs,
)


def get_plugin_headers(skill, token=None):
Comment thread
arorashivam96 marked this conversation as resolved.
"""Return HTTP headers for raw Web API calls, with plugin attribution.

Use this for operations the SDK does not support (forms, views, $apply,
N:N $expand, unbound actions).

IMPORTANT: Do not modify the User-Agent context — it uses a closed
schema (app/skill/agent) for safe server-side attribution. Never
include secrets, PII, or free-form text.

:param skill: Skill name (e.g. "dv-metadata").
:param token: Optional bearer token (from get_token()).
:returns: Headers dict with User-Agent and optional Authorization.
"""
_validate_skill(skill)
ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}"
if not _CONTEXT_RE.match(ctx_str):
raise ValueError(
f"operation_context failed format validation: {ctx_str!r}."
)
headers = {"User-Agent": f"Python-urllib ({ctx_str})"}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers


if __name__ == "__main__":
token = get_token()
print(token)
11 changes: 2 additions & 9 deletions .github/plugins/dataverse/scripts/enable-mcp-client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import os

sys.path.insert(0, os.path.dirname(__file__))
from auth import get_credential, load_env
from auth import get_client


def find_client(client, app_id):
Expand All @@ -29,20 +29,13 @@ def find_client(client, app_id):


def main():
load_env()
env_url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
client = get_client("dv-connect")
mcp_client_id = os.environ.get("MCP_CLIENT_ID")

if not env_url:
print("ERROR: DATAVERSE_URL not set in .env", flush=True)
sys.exit(1)
if not mcp_client_id:
print("ERROR: MCP_CLIENT_ID not set in .env", flush=True)
sys.exit(1)

from PowerPlatform.Dataverse.client import DataverseClient
client = DataverseClient(base_url=env_url, credential=get_credential())

print(f"Looking up MCP client {mcp_client_id}...", flush=True)
record = find_client(client, mcp_client_id)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ Settings like search mode, MCP, copilot features, fabric, and retention live ins
import os, sys, json, urllib.request
from xml.etree import ElementTree as ET
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_token, load_env # SDK does not support orgdborgsettings XML blob
from auth import get_token, get_plugin_headers, load_env # SDK does not support orgdborgsettings XML blob

load_env()
env_url = os.environ["DATAVERSE_URL"].rstrip("/")
token = get_token()
_headers = get_plugin_headers("dv-admin", token)
_headers["Accept"] = "application/json"

req = urllib.request.Request(
f"{env_url}/api/data/v9.2/organizations?$select=organizationid,orgdborgsettings",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
headers=_headers,
)
with urllib.request.urlopen(req) as resp:
org = json.loads(resp.read())["value"][0]
Expand All @@ -41,7 +43,7 @@ for child in sorted(root, key=lambda c: c.tag):
import os, sys, json, urllib.request, urllib.error
from xml.etree import ElementTree as ET
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_token, load_env # SDK does not support orgdborgsettings XML blob
from auth import get_token, get_plugin_headers, load_env # SDK does not support orgdborgsettings XML blob

load_env()
env_url = os.environ["DATAVERSE_URL"].rstrip("/")
Expand All @@ -50,13 +52,13 @@ token = get_token()
SETTING_NAME = "SearchAndCopilotIndexMode" # PascalCase, case-sensitive
SETTING_VALUE = "0" # always a string in XML

headers = {
"Authorization": f"Bearer {token}",
headers = get_plugin_headers("dv-admin", token)
headers.update({
"Accept": "application/json",
"Content-Type": "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
}
})

# Fetch current XML
req = urllib.request.Request(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ Recycle bin settings live in the `recyclebinconfigs` entity, NOT in `orgdborgset
```python
import os, sys, json, urllib.request, urllib.parse
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_token, load_env # SDK does not support recyclebinconfigs entity
from auth import get_token, get_plugin_headers, load_env # SDK does not support recyclebinconfigs entity

load_env()
env_url = os.environ["DATAVERSE_URL"].rstrip("/")
token = get_token()

ORGANIZATION_ENTITY_ID = "e1bd1119-6e9d-45a4-bc15-12051e65a0bd"

headers = {
"Authorization": f"Bearer {token}",
headers = get_plugin_headers("dv-admin", token)
headers.update({
"Accept": "application/json",
"Content-Type": "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
}
})

# Fetch org-level config by extensionofrecordid (NOT by name)
filter_q = urllib.parse.quote(f"_extensionofrecordid_value eq '{ORGANIZATION_ENTITY_ID}'")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ Allowlisted uniquenames (both `datatype=2` bool, stored as string `"true"`/`"fal
```python
import os, sys, json, urllib.request, urllib.parse
sys.path.insert(0, os.path.join(os.getcwd(), "scripts"))
from auth import get_token, load_env # SDK does not support settingdefinition/organizationsettings entities
from auth import get_token, get_plugin_headers, load_env # SDK does not support settingdefinition/organizationsettings entities

load_env()
env_url = os.environ["DATAVERSE_URL"].rstrip("/")
headers = {
"Authorization": f"Bearer {get_token()}",
token = get_token()
headers = get_plugin_headers("dv-admin", token)
headers.update({
"Accept": "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
"Content-Type": "application/json",
}
})

UNIQUENAME = "PowerAppsAppLevelSecurityRolesEnabled" # or PlanShareSecurityRolesEnabled

Expand Down
Loading
Loading