avr-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 24" fill="none" role="img" aria-label="Avrea logo">
2
+ <g clip-path="url(#avrea-logo-clip)">
3
+ <path d="M43.641 7.08765H45.892L47.9816 12.1759C48.4867 13.4141 48.9463 14.5614 49.4293 15.7984H49.4748C49.98 14.5146 50.463 13.3231 50.9903 12.0382L53.0343 7.08765H55.1941L50.4617 18.3659H48.3943L43.6398 7.08765H43.641Z" fill="white"/>
4
+ <path d="M57.604 7.08771H59.6714V8.73787L59.717 8.76124C60.4981 7.66072 61.7388 6.99548 63.0707 6.99548C63.3011 6.99548 63.5537 7.01885 63.8062 7.08771V9.12767C63.5302 9.06004 63.2543 9.03668 63.0017 9.03668C61.4628 9.03668 60.3835 9.81626 59.6714 11.0311V18.3671H57.604V7.08771Z" fill="white"/>
5
+ <path d="M71.7186 18.6179C68.1345 18.6179 65.5853 16.348 65.5853 12.7723C65.5853 9.19648 68.1123 6.83435 71.19 6.83435C73.9695 6.83435 76.3573 8.75996 76.3573 12.3136C76.3573 12.6579 76.3351 13.1166 76.3117 13.3674H67.6749C67.9509 15.5451 69.6043 16.7612 71.8553 16.7612C73.3252 16.7612 74.657 16.2337 75.7376 15.4553V17.5641C74.7038 18.2294 73.1416 18.6179 71.7173 18.6179H71.7186ZM74.3601 11.6951C74.1999 9.49528 72.5921 8.69233 71.1678 8.69233C69.6056 8.69233 68.0445 9.67726 67.6996 11.6951H74.3601Z" fill="white"/>
6
+ <path d="M88.301 7.63608C87.3597 7.15529 86.326 6.83313 85.1543 6.83313C81.6749 6.83313 79.2354 9.32806 79.1874 12.7366C79.2367 16.1452 81.6762 18.6401 85.1543 18.6401C86.326 18.6401 87.3597 18.3191 88.301 17.8371L90.0012 18.2269V7.24629L88.301 7.63608ZM87.9338 16.0271C87.2217 16.4858 86.2114 16.8067 85.1777 16.8067C82.7776 16.8067 81.2893 15.036 81.2745 12.7378C81.2893 10.4384 82.7776 8.66897 85.1777 8.66897C86.2114 8.66897 87.2217 8.98868 87.9338 9.44856V16.0283V16.0271Z" fill="white"/>
7
+ <path d="M39.0639 7.63608C38.1226 7.15529 37.0889 6.83313 35.9172 6.83313C32.4379 6.83313 29.9984 9.32806 29.9503 12.7366C29.9996 16.1452 32.4391 18.6401 35.9172 18.6401C37.0889 18.6401 38.1226 18.3191 39.0639 17.8371L40.7642 18.2269V7.24629L39.0639 7.63608ZM38.6968 16.0271C37.9846 16.4858 36.9743 16.8067 35.9406 16.8067C33.5406 16.8067 32.0522 15.036 32.0374 12.7378C32.0522 10.4384 33.5406 8.66897 35.9406 8.66897C36.9743 8.66897 37.9846 8.98868 38.6968 9.44856V16.0283V16.0271Z" fill="white"/>
8
+ <path d="M15.6435 21.8875C15.5461 21.8789 15.232 21.8433 15.142 21.8347C14.3239 21.7117 14.8488 19.8906 14.8291 19.6336C14.8242 19.5377 14.1736 18.4851 14.1736 18.4851C14.0282 17.9626 13.3752 15.3988 14.2611 14.2195C14.5617 13.8175 15.0065 13.606 15.5424 13.606C15.9774 13.606 16.4677 13.7388 17.0419 14.013C17.2316 14.104 17.345 14.3032 17.3265 14.5134L16.2607 21.4732C16.193 21.8457 16.055 21.9318 15.6422 21.8851L15.6435 21.8875Z" fill="#D88D26"/>
9
+ <path d="M15.5425 13.6084C15.9774 13.6084 16.4677 13.7412 17.0419 14.0154C17.2316 14.1064 17.345 14.3056 17.3265 14.5159L16.2608 21.4756C16.2041 21.7892 16.0981 21.8986 15.8209 21.8986C15.7679 21.8986 15.7088 21.8949 15.6423 21.8875C15.5449 21.8789 15.2308 21.8433 15.1408 21.8347C14.3215 21.7117 14.8476 19.8906 14.8279 19.6336C14.8229 19.5377 14.1724 18.4851 14.1724 18.4851C14.027 17.9626 13.374 15.3988 14.2599 14.2195C14.5605 13.8175 15.0053 13.606 15.5412 13.606M15.5425 12.7501C14.7355 12.7501 14.0356 13.0895 13.5736 13.7068C12.5054 15.1282 13.0586 17.6834 13.3469 18.7151C13.3691 18.7926 13.4011 18.8663 13.443 18.9352C13.6229 19.2266 13.8324 19.5771 13.9482 19.7824C13.9445 19.8033 13.942 19.8242 13.9383 19.8427C13.8016 20.6813 13.7042 21.5519 14.1478 22.1581C14.356 22.4433 14.664 22.629 15.0151 22.6819C15.0299 22.6843 15.0447 22.6856 15.0595 22.6868C15.0989 22.6905 15.1839 22.7003 15.2739 22.7089C15.3835 22.72 15.4993 22.7323 15.556 22.7384C15.6533 22.7495 15.7408 22.7544 15.8221 22.7544C16.2263 22.7544 16.931 22.6081 17.1072 21.6256C17.1084 21.6183 17.1097 21.6109 17.1109 21.6035L18.1766 14.6438C18.1791 14.6253 18.1815 14.6069 18.1828 14.5884C18.2321 14.0253 17.9216 13.4842 17.4127 13.2408C16.7203 12.91 16.1092 12.7489 15.5425 12.7489V12.7501Z" fill="#5D3B20"/>
10
+ <path d="M16.6637 0.857056C16.8522 0.857056 17.0383 0.898863 17.207 0.988627C17.8255 1.31202 17.9241 2.16293 17.791 2.79004C17.7652 2.913 17.8231 3.03597 17.9364 3.09007C18.6215 3.42084 20.286 4.2988 21.7287 5.5641C22.9472 6.63388 22.2721 10.5048 21.6018 11.1884C21.4478 11.3483 21.4256 11.368 21.3234 11.368C21.2568 11.368 21.2076 11.3434 21.1755 11.2991C20.9981 11.0544 21.6302 9.25915 21.0683 9.25915C21.04 9.25915 21.008 9.26775 20.971 9.28374C20.4782 9.50507 20.7874 11.5131 19.6798 11.9729C19.0367 12.2398 18.4502 12.3984 17.7701 12.3984C15.3121 12.3984 13.7979 10.9437 13.2607 10.5908C13.2546 10.5872 13.2472 10.5847 13.241 10.5847C13.2164 10.5847 13.1967 10.6105 13.209 10.6376C13.464 11.1602 14.9228 12.4476 16.4875 12.9517C17.2329 13.162 17.4227 13.0858 17.4227 13.0858C17.4227 13.0858 17.5335 15.561 16.798 16.7611C16.1191 17.869 14.2353 19.1651 14.2353 19.1651C14.2291 19.2524 14.1971 19.4491 14.1983 19.4762L13.7782 22.5244C13.7055 23.0433 13.5922 23.1417 13.2866 23.1417C12.524 23.0888 12.2911 23.2389 12.1938 22.383C12.1248 22.0277 11.8808 20.8017 11.7724 20.2496C11.5223 20.3923 11.1416 20.4464 10.7596 20.5042C10.3395 20.5669 9.06925 20.8054 8.44459 20.7685C8.22282 20.755 9.86023 19.8254 9.74195 18.3548C9.69883 17.8137 9.61505 17.5063 9.45611 18.4728C9.13454 20.4181 5.81659 20.9124 5.81659 20.9124C9.65694 21.6428 10.7313 22.0105 10.7941 22.2736L10.8447 22.5564C10.9013 22.7864 10.7757 23.0089 10.5391 23.0089L5.93856 22.506C3.96972 22.2441 0.859985 21.2419 0.859985 16.9837C0.859985 13.1423 3.49538 11.668 7.13736 9.70673C8.56656 8.93698 7.70165 6.20842 8.74275 4.30741C8.79326 4.21519 8.7908 4.10575 8.73659 4.01722C8.70579 3.9668 8.67622 3.91639 8.64788 3.86228C8.06511 2.75684 8.2339 1.52352 9.01996 1.11282C9.21586 1.00953 9.4327 0.965263 9.65324 0.965263C10.3186 0.965263 11.0134 1.37842 11.266 1.88503C11.2907 1.93299 11.4742 2.31663 11.5506 2.48263C11.5691 2.52075 11.6073 2.54534 11.648 2.54534H11.664C12.0582 2.48509 12.4969 2.45066 12.9884 2.44697H13.0981C13.6722 2.44697 14.2402 2.48509 14.7947 2.55149H14.8082C14.8612 2.55149 14.9055 2.5146 14.9154 2.46296C14.9536 2.27728 15.0201 2.09776 15.1138 1.92069C15.4612 1.25669 16.0822 0.857056 16.6625 0.857056H16.6662H16.6637Z" fill="#D88D26"/>
11
+ <path d="M16.6637 0.857055C16.8522 0.857055 17.0382 0.898863 17.207 0.988626C17.8255 1.31202 17.9241 2.16293 17.791 2.79004C17.7652 2.913 17.8231 3.03597 17.9364 3.09007C18.6215 3.42084 20.286 4.2988 21.7287 5.56409C22.9472 6.63388 22.2721 10.5048 21.6018 11.1884C21.4478 11.3483 21.4256 11.368 21.3234 11.368C21.2568 11.368 21.2088 11.3434 21.1755 11.2991C20.9981 11.0544 21.6302 9.25915 21.0683 9.25915C21.04 9.25915 21.008 9.26775 20.971 9.28374C20.4782 9.50507 20.7874 11.5131 19.6798 11.9729C19.0367 12.2398 18.4502 12.3984 17.7701 12.3984C15.3121 12.3984 13.7979 10.9437 13.2607 10.5908C13.2546 10.5872 13.2472 10.5847 13.241 10.5847C13.2164 10.5847 13.1967 10.6105 13.209 10.6376C13.464 11.1602 14.9228 12.4476 16.4875 12.9517C16.9212 13.0735 17.1676 13.0993 17.297 13.0993C17.3894 13.0993 17.4227 13.0858 17.4227 13.0858C17.4227 13.0858 17.5335 15.561 16.798 16.7611C16.1191 17.869 14.2353 19.1651 14.2353 19.1651C14.2291 19.2524 14.1971 19.4491 14.1983 19.4762L13.7782 22.5244C13.7055 23.0433 13.5922 23.1417 13.2866 23.1417C12.524 23.0888 12.2911 23.2389 12.1938 22.383C12.1248 22.0277 11.8808 20.8017 11.7724 20.2496C11.5223 20.3923 11.1416 20.4464 10.7596 20.5042C10.3691 20.5632 9.2442 20.7722 8.58627 20.7722C8.53576 20.7722 8.48894 20.7722 8.44458 20.7685C8.22281 20.755 9.86023 19.8254 9.74195 18.3548C9.71854 18.067 9.68528 17.8457 9.63353 17.8457C9.58794 17.8457 9.53003 18.0191 9.45488 18.4728C9.13331 20.4181 5.81535 20.9124 5.81535 20.9124C9.65571 21.6428 10.7301 22.0105 10.7929 22.2736L10.8434 22.5564C10.9001 22.7864 10.7744 23.0089 10.5379 23.0089L5.93733 22.506C3.96849 22.2441 0.85875 21.2419 0.85875 16.9837C0.85875 13.1423 3.49414 11.668 7.13613 9.70673C8.56533 8.93698 7.70042 6.20842 8.74151 4.30741C8.79203 4.21519 8.78956 4.10575 8.73535 4.01721C8.70455 3.9668 8.67498 3.91638 8.64664 3.86228C8.06388 2.75684 8.23267 1.52352 9.01873 1.11282C9.21463 1.00953 9.43147 0.965263 9.65201 0.965263C10.3173 0.965263 11.0122 1.37842 11.2648 1.88503C11.2894 1.93298 11.473 2.31663 11.5494 2.48263C11.5679 2.52075 11.6061 2.54534 11.6467 2.54534H11.6627C12.057 2.48509 12.4956 2.45066 12.9872 2.44697H13.0969C13.671 2.44697 14.239 2.48509 14.7934 2.55149H14.807C14.86 2.55149 14.9043 2.5146 14.9142 2.46296C14.9524 2.27728 15.0189 2.09776 15.1125 1.92069C15.46 1.25669 16.0809 0.857055 16.6612 0.857055H16.6649M16.6637 0H16.66C15.7421 0 14.8353 0.597602 14.3499 1.52352C14.3302 1.56164 14.3105 1.60098 14.292 1.64033C13.8854 1.60713 13.485 1.58992 13.0956 1.58992H12.9798C12.673 1.59238 12.3773 1.6059 12.0952 1.63172C12.0644 1.56901 12.041 1.52106 12.0324 1.50384C11.6295 0.697203 10.6278 0.110667 9.65078 0.110667C9.27623 0.110667 8.92879 0.193053 8.61831 0.356594C7.99488 0.683677 7.57475 1.32432 7.46756 2.11497C7.37761 2.78266 7.51068 3.51429 7.84457 4.18445C7.4195 5.11405 7.33326 6.12481 7.25687 7.02613C7.18172 7.9127 7.11149 8.75008 6.72708 8.95666C3.11836 10.8982 0 12.5767 0 16.9849C0 20.5951 2.06864 22.8577 5.82398 23.3569H5.83384L5.84369 23.3594L10.4442 23.8623L10.491 23.8672H10.5379C10.9013 23.8672 11.2377 23.7061 11.4582 23.4245C11.4927 23.3803 11.5235 23.3348 11.5519 23.2868C11.6307 23.4381 11.7367 23.577 11.8845 23.6938C12.2258 23.9631 12.604 23.9742 12.9379 23.9852C13.0291 23.9877 13.1227 23.9914 13.225 23.9988L13.2546 24.0012H13.2841C14.4361 24.0012 14.579 22.9806 14.6259 22.6449L15.0398 19.6434C15.6522 19.1921 16.9323 18.1814 17.5274 17.2112C18.2777 15.9877 18.2974 13.8949 18.2839 13.2321C18.8334 13.1755 19.3829 13.0255 20.0075 12.7673C20.3722 12.616 20.6396 12.3849 20.8392 12.1144C20.9846 12.1881 21.1484 12.2287 21.3209 12.2287C21.7916 12.2287 22.0269 11.9852 22.2179 11.7885C22.9078 11.079 23.3119 9.02183 23.2565 7.54135C23.2109 6.32401 22.8869 5.44359 22.2955 4.92346C20.9809 3.77129 19.5233 2.94129 18.6941 2.51337C18.7533 1.6305 18.4551 0.675069 17.605 0.231171C17.3192 0.0811559 16.9927 0.00245927 16.6637 0.00245927V0ZM11.0085 21.3354C11.0418 21.3305 11.0738 21.3255 11.1071 21.3206C11.1132 21.3489 11.1182 21.3772 11.1243 21.4055C11.0874 21.3809 11.0492 21.3587 11.0085 21.3354Z" fill="#5D3B20"/>
12
+ <path d="M9.45611 18.474C9.13454 20.4193 5.81659 20.9136 5.81659 20.9136C9.65694 21.644 10.7313 22.0117 10.7941 22.2748L10.8447 22.5576C10.9013 22.7876 10.7757 23.0101 10.5391 23.0101L5.93856 22.5072C3.96972 22.2453 0.859985 21.2432 0.859985 16.9849C0.859985 16.9849 1.21852 12.2877 5.76854 13.103C10.5095 13.9514 9.45735 18.474 9.45735 18.474H9.45611Z" fill="#DEA752"/>
13
+ <path d="M20.3993 5.73014L18.4945 6.1556L18.8309 8.33697C18.8309 8.33697 21.0092 7.89799 21.035 8.92473C21.035 8.92473 21.2962 7.96562 22.3977 7.71108C22.3977 6.03755 21.7077 5.53955 21.7077 5.53955L20.398 5.72891L20.3993 5.73014Z" fill="#BB7624"/>
14
+ <path d="M11.6578 2.54785C11.6159 2.54785 9.32549 3.23522 8.74149 4.30869C7.67699 6.21339 8.56531 8.969 7.13611 9.70801C7.13611 9.70801 8.49631 9.06245 11.0775 11.4111C13.6119 13.6724 13.4689 11.1664 13.2139 10.6438C13.2016 10.6179 13.2225 10.5909 13.2459 10.5909C13.2521 10.5909 13.2595 10.5909 13.2656 10.597C13.8053 10.9499 15.3195 12.4046 17.775 12.4046C18.4539 12.4046 19.8412 7.36187 18.6559 6.21954C17.2021 4.81776 12.2603 2.71262 11.6553 2.54785" fill="#DEA752"/>
15
+ <path d="M11.6578 2.54783H11.6739C12.0681 2.48758 12.5067 2.45315 12.9983 2.44946H13.108C13.6821 2.44946 14.2501 2.48758 14.8045 2.55398H14.8181C14.8711 2.55398 17.8342 3.03846 17.9475 3.09256C18.6326 3.42333 20.2737 4.32711 21.7398 5.56659C22.0996 5.87154 21.6856 6.14943 20.8441 6.29453C17.5102 6.87 20.0926 5.83711 11.6566 2.5466L11.6591 2.54906L11.6578 2.54783Z" fill="#E6BE86"/>
16
+ <path d="M19.6219 7.47492C19.4519 8.07621 17.9784 7.802 18.1484 7.15029C18.4096 6.63631 19.6441 6.86994 19.6256 7.46139L19.6219 7.47492Z" fill="#5D3B20"/>
17
+ <path d="M23.068 6.30186C23.3341 6.86749 22.0392 7.62249 21.7411 7.01874C21.6006 6.45925 22.6885 5.82968 23.0618 6.28833L23.0717 6.2994L23.0692 6.30186H23.068Z" fill="#5D3B20"/>
18
+ <path d="M13.05 4.89639C13.549 4.89639 14.0037 5.05747 14.356 5.3243C14.3942 5.30586 14.3782 5.31446 14.4152 5.29602C14.3905 4.65046 13.7905 4.13647 13.0513 4.13647C12.312 4.13647 11.7108 4.65292 11.6874 5.29602C11.7256 5.31446 11.7096 5.30586 11.7465 5.3243C12.0964 5.05747 12.5535 4.89639 13.0525 4.89639H13.05Z" fill="#5D3B20"/>
19
+ </g>
20
+ <defs>
21
+ <clipPath id="avrea-logo-clip">
22
+ <rect width="90" height="24" fill="white"/>
23
+ </clipPath>
24
+ </defs>
25
+ </svg>
avrea_cli/audit.py ADDED
@@ -0,0 +1,153 @@
1
+ """Audit events CLI commands."""
2
+
3
+ from avrea_cli.api_client import ApiClient
4
+ from avrea_cli.config import CliConfig
5
+ from avrea_cli.helpers import ensure_authenticated
6
+ from avrea_cli.helpers import ensure_ctx
7
+ from avrea_cli.helpers import get_org_id
8
+ from avrea_cli.helpers import handle_http_error
9
+ from avrea_cli.helpers import validate_cursor
10
+ from avrea_cli.json_output import emit_json
11
+ from avrea_cli.json_output import handle_json_meta
12
+ from avrea_cli.json_output import make_schema
13
+ from avrea_cli.json_output import split_fields
14
+ from avrea_cli.output import format_timestamp
15
+ from avrea_cli.output import output_list
16
+ import click
17
+ import httpx
18
+ import json
19
+
20
+ _AUDIT_FIELDS = make_schema(
21
+ "event_id",
22
+ "created_at",
23
+ "actor_user_id",
24
+ "actor_type",
25
+ "acting_api_key_id",
26
+ "client_ip",
27
+ "resource_type",
28
+ "resource_id",
29
+ "action",
30
+ "event_data",
31
+ )
32
+
33
+
34
+ @click.group("audit-events")
35
+ @click.pass_context
36
+ def audit_events(ctx):
37
+ """View audit events for organization writes."""
38
+ ensure_ctx(ctx)
39
+
40
+
41
+ @audit_events.command("list")
42
+ @click.option("--org", "org_id", help="Organization ID. Uses default org if not specified (see: avr config set org).")
43
+ @click.option("--resource-type", "resource_type", default=None, help="Filter by resource type (e.g. api_key, user).")
44
+ @click.option("--action", "action", default=None, help="Filter by action (CREATE, UPDATE, DELETE, ...).")
45
+ @click.option("--actor-user-id", "actor_user_id", default=None, help="Filter by acting user id.")
46
+ @click.option(
47
+ "--from",
48
+ "--created-after",
49
+ "created_after",
50
+ default=None,
51
+ help="ISO-8601 lower bound (inclusive) on created_at.",
52
+ )
53
+ @click.option(
54
+ "--to",
55
+ "--created-before",
56
+ "created_before",
57
+ default=None,
58
+ help="ISO-8601 upper bound (exclusive) on created_at.",
59
+ )
60
+ @click.option(
61
+ "-L",
62
+ "--limit",
63
+ type=click.IntRange(1, 1000),
64
+ default=100,
65
+ show_default=True,
66
+ help="Max events to return.",
67
+ )
68
+ @click.option("--cursor", default=None, help="Opaque cursor from a previous response's next_cursor.")
69
+ @click.option(
70
+ "--json",
71
+ "json_fields",
72
+ default=None,
73
+ help='Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields.',
74
+ )
75
+ @click.option("-q", "--jq", "jq_expr", default=None, help="Filter --json output through a jq expression.")
76
+ @click.pass_context
77
+ def audit_events_list(
78
+ ctx,
79
+ org_id,
80
+ resource_type,
81
+ action,
82
+ actor_user_id,
83
+ created_after,
84
+ created_before,
85
+ limit,
86
+ cursor,
87
+ json_fields,
88
+ jq_expr,
89
+ ):
90
+ """List audit events for the organization.
91
+
92
+ \b
93
+ JSON FIELDS
94
+ acting_api_key_id, action, actor_type, actor_user_id, client_ip,
95
+ created_at, event_data, event_id, resource_id, resource_type
96
+ """
97
+ if handle_json_meta(json_fields, jq_expr, _AUDIT_FIELDS):
98
+ return
99
+
100
+ client: ApiClient = ctx.obj["client"]
101
+ config: CliConfig = ctx.obj["config"]
102
+ ensure_authenticated(config)
103
+ org_id = get_org_id(config, org_id, client=client)
104
+ cursor = validate_cursor(cursor)
105
+
106
+ params: dict = {"limit": limit}
107
+ if resource_type:
108
+ params["resource_type"] = resource_type
109
+ if action:
110
+ params["action"] = action
111
+ if actor_user_id:
112
+ params["actor_user_id"] = actor_user_id
113
+ if created_after:
114
+ params["created_after"] = created_after
115
+ if created_before:
116
+ params["created_before"] = created_before
117
+ if cursor:
118
+ params["cursor"] = cursor
119
+
120
+ try:
121
+ response = client.public_get(f"/orgs/{org_id}/audit-events", params=params)
122
+ except httpx.HTTPStatusError as exc:
123
+ handle_http_error(exc, "list audit events")
124
+
125
+ rows = response.get("data", [])
126
+
127
+ if json_fields is not None:
128
+ emit_json(rows, split_fields(json_fields, _AUDIT_FIELDS), _AUDIT_FIELDS, jq_expr)
129
+ return
130
+
131
+ for row in rows:
132
+ row["created_at_display"] = format_timestamp(row.get("created_at"))
133
+ row["target"] = row.get("resource_id") or "-"
134
+ row["actor"] = row.get("actor_user_id") or row.get("actor_type") or "-"
135
+ row["event_data_display"] = json.dumps(row.get("event_data") or {}, separators=(",", ":"))
136
+
137
+ output_list(
138
+ rows,
139
+ columns=[
140
+ "created_at_display",
141
+ "actor",
142
+ "actor_type",
143
+ "resource_type",
144
+ "target",
145
+ "action",
146
+ "event_data_display",
147
+ ],
148
+ column_labels=["Time", "Actor", "Type", "Resource", "Target", "Action", "Data"],
149
+ )
150
+
151
+ next_cursor = (response.get("pagination") or {}).get("next_cursor")
152
+ if next_cursor:
153
+ click.echo(f"\nMore results available. Re-run with --cursor {next_cursor}", err=True)
avrea_cli/auth.py ADDED
@@ -0,0 +1,457 @@
1
+ """CLI authentication via browser OAuth flow.
2
+
3
+ Credentials live in ``hosts.json`` keyed by full API URL:
4
+
5
+ {
6
+ "default_host": "https://api.avrea.com",
7
+ "hosts": {
8
+ "https://api.avrea.com": {
9
+ "token": "avk_...",
10
+ "default_org": "org-..."
11
+ }
12
+ }
13
+ }
14
+
15
+ ``default_host`` is the URL used when ``AVR_HOST`` isn't set. It's written
16
+ on first login (when there are no other entries yet) and updated explicitly
17
+ via ``avr auth switch <url>``. Multiple host entries accumulate naturally
18
+ when the user logs in to additional API endpoints (e.g. self-hosted Avrea).
19
+ """
20
+
21
+ from avrea_cli.paths import PATHS
22
+ from http.server import BaseHTTPRequestHandler
23
+ from http.server import HTTPServer
24
+ from pathlib import Path
25
+ from typing import Any
26
+ from typing import override
27
+ from urllib.parse import parse_qs
28
+ from urllib.parse import urlparse
29
+ import click
30
+ import html as html_module
31
+ import httpx
32
+ import json
33
+ import os
34
+ import socket
35
+ import webbrowser
36
+
37
+ # Sibling asset so the file stays byte-identical to the console's Logo.
38
+ # Update this file when the console Logo changes.
39
+ _LOGO_SVG = (Path(__file__).parent / "assets" / "logo_mark.svg").read_text(encoding="utf-8")
40
+
41
+ HOSTS_FILE = PATHS.hosts_file
42
+
43
+
44
+ def _read() -> dict[str, Any]:
45
+ """Load the hosts file. Returns an empty wrapper if missing or malformed
46
+ — callers treat absence as 'no credentials yet'."""
47
+ empty: dict[str, Any] = {"default_host": None, "hosts": {}}
48
+ if not HOSTS_FILE.exists():
49
+ return empty
50
+ try:
51
+ data = json.loads(HOSTS_FILE.read_text())
52
+ except OSError, ValueError:
53
+ return empty
54
+ if not isinstance(data, dict):
55
+ return empty
56
+ hosts = data.get("hosts")
57
+ if not isinstance(hosts, dict):
58
+ return empty
59
+ default_host = data.get("default_host")
60
+ return {
61
+ "default_host": default_host if isinstance(default_host, str) and default_host in hosts else None,
62
+ "hosts": hosts,
63
+ }
64
+
65
+
66
+ def _write(payload: dict[str, Any]) -> None:
67
+ HOSTS_FILE.parent.mkdir(parents=True, exist_ok=True)
68
+ HOSTS_FILE.write_text(json.dumps(payload, indent=2))
69
+ # Contains a bearer token; refuse group/other read. On Windows os.chmod
70
+ # only toggles the read-only bit — the file inherits NTFS ACLs from
71
+ # %USERPROFILE%, which is typically already user-private. A dedicated
72
+ # icacls / pywin32 hardening pass is a TODO if we ship on shared Windows
73
+ # boxes.
74
+ HOSTS_FILE.chmod(0o600)
75
+
76
+
77
+ def list_hosts() -> list[str]:
78
+ """All hosts the user has stored credentials for, in insertion order."""
79
+ return list(_read()["hosts"].keys())
80
+
81
+
82
+ def load_default_host() -> str | None:
83
+ """The pinned default URL, or None if unset.
84
+
85
+ ``CliConfig`` falls back to ``https://api.avrea.com`` when this is None,
86
+ so a brand-new install still has somewhere to point ``avr auth login``.
87
+ """
88
+ return _read()["default_host"]
89
+
90
+
91
+ def set_default_host(host: str) -> None:
92
+ """Pin ``host`` as the default for commands without ``AVR_HOST``. The
93
+ host must already have credentials stored; we don't auto-create empty
94
+ entries — that would let `auth switch` silently misconfigure the file."""
95
+ payload = _read()
96
+ if host not in payload["hosts"]:
97
+ raise KeyError(host)
98
+ payload["default_host"] = host
99
+ _write(payload)
100
+
101
+
102
+ def load_token(*, host: str) -> str | None:
103
+ """API key for ``host``, or None if not authenticated against it."""
104
+ entry = _read()["hosts"].get(host)
105
+ if not isinstance(entry, dict):
106
+ return None
107
+ token = entry.get("token")
108
+ return token if isinstance(token, str) and token else None
109
+
110
+
111
+ def store_token(token: str, *, host: str) -> None:
112
+ """Persist ``token`` under ``host``. Preserves any default_org already
113
+ there. Promotes ``host`` to ``default_host`` when no default is set —
114
+ a first-time login should "just work" without an extra ``auth switch``.
115
+ """
116
+ payload = _read()
117
+ entry = payload["hosts"].setdefault(host, {})
118
+ if not isinstance(entry, dict):
119
+ # Replaces a corrupted entry rather than throwing — the auth flow
120
+ # has already issued a valid key, refusing to save would be worse.
121
+ entry = {}
122
+ payload["hosts"][host] = entry
123
+ entry["token"] = token
124
+ if not payload["default_host"]:
125
+ payload["default_host"] = host
126
+ _write(payload)
127
+
128
+
129
+ def load_default_org(*, host: str) -> str | None:
130
+ entry = _read()["hosts"].get(host)
131
+ if not isinstance(entry, dict):
132
+ return None
133
+ org = entry.get("default_org")
134
+ return org if isinstance(org, str) and org else None
135
+
136
+
137
+ def store_default_org(org_id: str, *, host: str) -> None:
138
+ payload = _read()
139
+ entry = payload["hosts"].setdefault(host, {})
140
+ if not isinstance(entry, dict):
141
+ entry = {}
142
+ payload["hosts"][host] = entry
143
+ entry["default_org"] = org_id
144
+ _write(payload)
145
+
146
+
147
+ def clear_default_org(*, host: str) -> bool:
148
+ """Drop the stored default org for ``host``. Returns True if the entry
149
+ existed. Leaves the rest of the host record (token, etc.) intact."""
150
+ payload = _read()
151
+ entry = payload["hosts"].get(host)
152
+ if not isinstance(entry, dict) or "default_org" not in entry:
153
+ return False
154
+ del entry["default_org"]
155
+ _write(payload)
156
+ return True
157
+
158
+
159
+ def clear(*, host: str) -> bool:
160
+ """Remove the host's entry. Returns True if anything was removed.
161
+
162
+ If the cleared host was ``default_host``, promote another stored host
163
+ to default (insertion order) so the CLI keeps working without a manual
164
+ ``auth switch``. If no hosts remain, unlink the file entirely."""
165
+ payload = _read()
166
+ if host not in payload["hosts"]:
167
+ return False
168
+ payload["hosts"].pop(host)
169
+ if payload["default_host"] == host:
170
+ remaining = list(payload["hosts"].keys())
171
+ payload["default_host"] = remaining[0] if remaining else None
172
+ if payload["hosts"]:
173
+ _write(payload)
174
+ else:
175
+ # Empty file would still satisfy `hosts.json exists`; remove it so a
176
+ # fresh `auth login` starts from a clean state.
177
+ try:
178
+ HOSTS_FILE.unlink()
179
+ except FileNotFoundError:
180
+ pass
181
+ return True
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Browser OAuth flow
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
190
+ """Handle OAuth callback and extract session cookie and CSRF token."""
191
+
192
+ session_cookie = None
193
+ csrf_token = None
194
+ # Set by login() before the server runs so the handler can call /users/me
195
+ # with the just-issued session cookie and surface the email on the page.
196
+ public_api_url: str | None = None
197
+
198
+ @override
199
+ def log_message(self, format, *args):
200
+ pass
201
+
202
+ def do_GET(self):
203
+ # Query-param path covers localhost redirects; the cookie-header path
204
+ # covers same-domain redirects.
205
+ query = parse_qs(urlparse(self.path).query)
206
+ if "session" in query:
207
+ OAuthCallbackHandler.session_cookie = query["session"][0]
208
+ if "csrf_token" in query:
209
+ OAuthCallbackHandler.csrf_token = query["csrf_token"][0]
210
+ if not OAuthCallbackHandler.session_cookie:
211
+ cookie_header = self.headers.get("Cookie", "")
212
+ if "avrea_session=" in cookie_header:
213
+ for cookie in cookie_header.split(";"):
214
+ if "avrea_session=" in cookie:
215
+ OAuthCallbackHandler.session_cookie = cookie.split("=", 1)[1].strip()
216
+
217
+ email = self._fetch_email()
218
+
219
+ self.send_response(200)
220
+ self.send_header("Content-Type", "text/html")
221
+ self.end_headers()
222
+ self.wfile.write(_render_success_html(email).encode("utf-8"))
223
+
224
+ def _fetch_email(self) -> str | None:
225
+ """Best-effort lookup so the page can show the signed-in user's email
226
+ instead of 'CLI session connected'. Failures are silent — the page still renders."""
227
+ if not OAuthCallbackHandler.session_cookie or not OAuthCallbackHandler.public_api_url:
228
+ return None
229
+ try:
230
+ r = httpx.get(
231
+ f"{OAuthCallbackHandler.public_api_url}/users/me",
232
+ cookies={"avrea_session": OAuthCallbackHandler.session_cookie},
233
+ timeout=3.0,
234
+ )
235
+ r.raise_for_status()
236
+ return r.json().get("email")
237
+ except httpx.HTTPError, ValueError, KeyError:
238
+ return None
239
+
240
+
241
+ def _render_success_html(email: str | None) -> str:
242
+ """Render the post-login confirmation page. Mirrors the console's
243
+ ``NotLoggedIn`` layout (LogoMark, Sorts Mill Goudy headline, theme palette).
244
+ Self-contained: SVG and palette are inlined; the only network fetch is the
245
+ Google Font."""
246
+ email_block = f'<p class="email">{html_module.escape(email)}</p>' if email else ""
247
+ return f"""<!doctype html>
248
+ <html lang="en">
249
+ <head>
250
+ <meta charset="utf-8">
251
+ <title>Authenticated · Avrea</title>
252
+ <link rel="preconnect" href="https://fonts.googleapis.com">
253
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
254
+ <link href="https://fonts.googleapis.com/css2?family=Sorts+Mill+Goudy&amp;display=swap" rel="stylesheet">
255
+ <style>
256
+ :root {{
257
+ color-scheme: dark;
258
+ --bg: linear-gradient(306deg, #0e1018 8.16%, #1a1622 89.66%);
259
+ --fg: #f1f1f1;
260
+ --muted: #a09aae;
261
+ --card: #26222e;
262
+ --card-border: rgba(255, 255, 255, 0.08);
263
+ --accent: #b7bdeb;
264
+ --code-bg: #1b1623;
265
+ }}
266
+ * {{ box-sizing: border-box; }}
267
+ html, body {{ margin: 0; padding: 0; min-height: 100dvh; }}
268
+ body {{
269
+ background: var(--bg);
270
+ color: var(--fg);
271
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
272
+ display: flex;
273
+ align-items: center;
274
+ justify-content: center;
275
+ padding: 16px;
276
+ }}
277
+ .box {{
278
+ background: var(--card);
279
+ border: 1px solid var(--card-border);
280
+ border-radius: 12px;
281
+ padding: 32px;
282
+ width: 100%;
283
+ max-width: 480px;
284
+ display: flex;
285
+ flex-direction: column;
286
+ align-items: center;
287
+ text-align: center;
288
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
289
+ }}
290
+ .logo {{
291
+ display: block;
292
+ }}
293
+ .logo svg {{
294
+ height: 32px;
295
+ width: auto;
296
+ display: block;
297
+ }}
298
+ .headline {{
299
+ font-family: "Sorts Mill Goudy", Iowan Old Style, "Palatino Linotype", Georgia, serif;
300
+ font-size: 30px;
301
+ line-height: 1.1;
302
+ margin: 24px 0 0;
303
+ color: var(--fg);
304
+ font-weight: 400;
305
+ }}
306
+ .subhead {{
307
+ margin: 16px 0 0;
308
+ font-size: 15px;
309
+ color: var(--fg);
310
+ }}
311
+ .email {{
312
+ margin: 6px 0 0;
313
+ font-size: 14px;
314
+ color: var(--muted);
315
+ }}
316
+ hr {{
317
+ border: 0;
318
+ border-top: 1px solid var(--card-border);
319
+ margin: 28px 0 24px;
320
+ width: 100%;
321
+ }}
322
+ .next, .close {{
323
+ margin: 0;
324
+ font-size: 14px;
325
+ color: var(--muted);
326
+ }}
327
+ .code-row {{
328
+ margin: 12px 0 24px;
329
+ }}
330
+ code {{
331
+ display: inline-block;
332
+ background: var(--code-bg);
333
+ color: var(--accent);
334
+ padding: 8px 12px;
335
+ border-radius: 8px;
336
+ font-family: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
337
+ font-size: 13px;
338
+ }}
339
+ </style>
340
+ </head>
341
+ <body>
342
+ <div class="box">
343
+ <span class="logo">{_LOGO_SVG}</span>
344
+ <h1 class="headline">You're all set.</h1>
345
+ <p class="subhead">Your CLI session is now connected.</p>
346
+ {email_block}
347
+ <hr>
348
+ <p class="next">Head back to your terminal and try:</p>
349
+ <p class="code-row"><code>&gt; avr status</code></p>
350
+ <p class="close">You can close this tab now.</p>
351
+ </div>
352
+ </body>
353
+ </html>
354
+ """
355
+
356
+
357
+ def login(public_api_url: str, *, provider: str = "github") -> str:
358
+ """
359
+ Perform browser OAuth login and return the API key.
360
+ Raises click.Abort on failure.
361
+ """
362
+ # Control plane 302s here after the upstream OAuth callback completes;
363
+ # localhost is not registered with the upstream provider directly —
364
+ # see oauth.py allowed_redirect_hosts.
365
+ port = 8765
366
+ callback_uri = f"http://localhost:{port}/callback"
367
+
368
+ # Handler class state must be cleared per-login: BaseHTTPRequestHandler
369
+ # is instantiated by HTTPServer per-request so we can't pass per-call
370
+ # values through the constructor, and a prior aborted run would
371
+ # otherwise leave stale session/csrf values behind.
372
+ OAuthCallbackHandler.session_cookie = None
373
+ OAuthCallbackHandler.csrf_token = None
374
+ OAuthCallbackHandler.public_api_url = public_api_url
375
+
376
+ try:
377
+ server = HTTPServer(("localhost", port), OAuthCallbackHandler)
378
+ except OSError as e:
379
+ click.echo(f"Error: Cannot bind to port {port}: {e}", err=True)
380
+ click.echo("Ensure no other process is using this port and try again.")
381
+ raise click.Abort() from None
382
+
383
+ if provider == "google":
384
+ auth_url = f"{public_api_url}/oauth/google/login?final_redirect={callback_uri}"
385
+ elif provider == "github":
386
+ auth_url = f"{public_api_url}/oauth/github/login?next={callback_uri}"
387
+ else:
388
+ raise click.ClickException(f"Unsupported auth provider '{provider}'")
389
+ click.echo(f"Opening browser to: {auth_url}")
390
+ click.echo("Waiting for authentication...")
391
+
392
+ # AVR_BROWSER takes precedence over BROWSER. Python's webbrowser honors
393
+ # BROWSER directly, so promote AVR_BROWSER into BROWSER for the open() call.
394
+ avr_browser = os.environ.get("AVR_BROWSER")
395
+ saved_browser = os.environ.get("BROWSER")
396
+ if avr_browser:
397
+ os.environ["BROWSER"] = avr_browser
398
+ try:
399
+ opened = webbrowser.open(auth_url)
400
+ finally:
401
+ if avr_browser:
402
+ if saved_browser is None:
403
+ os.environ.pop("BROWSER", None)
404
+ else:
405
+ os.environ["BROWSER"] = saved_browser
406
+
407
+ if not opened:
408
+ click.echo("Failed to open browser. Please visit:", err=True)
409
+ click.echo(auth_url)
410
+
411
+ server.timeout = 300
412
+ try:
413
+ server.handle_request()
414
+ finally:
415
+ # Release the listening socket so a retry in the same process (or a
416
+ # follow-up TUI flow) doesn't hit "Address already in use" while
417
+ # CPython GC catches up.
418
+ server.server_close()
419
+
420
+ if not OAuthCallbackHandler.session_cookie:
421
+ click.echo("Error: Authentication failed - no session received", err=True)
422
+ raise click.Abort()
423
+
424
+ # Security note: the session token arrives as a query parameter to localhost.
425
+ # Query parameters may appear in browser history. The session is short-lived
426
+ # and exchanged immediately for an API key, then invalidated below.
427
+ try:
428
+ headers = {}
429
+ if OAuthCallbackHandler.csrf_token:
430
+ headers["X-CSRF-Token"] = OAuthCallbackHandler.csrf_token
431
+ response = httpx.post(
432
+ f"{public_api_url}/users/me/api-keys",
433
+ cookies={"avrea_session": OAuthCallbackHandler.session_cookie},
434
+ headers=headers,
435
+ json={"name": f"CLI - {socket.gethostname()}", "expires_days": 90},
436
+ timeout=10.0,
437
+ )
438
+ response.raise_for_status()
439
+ api_key = response.json()["api_key"]
440
+
441
+ try:
442
+ logout_headers: dict[str, str] = {}
443
+ if OAuthCallbackHandler.csrf_token:
444
+ logout_headers["X-CSRF-Token"] = OAuthCallbackHandler.csrf_token
445
+ httpx.post(
446
+ f"{public_api_url}/users/me/logout",
447
+ cookies={"avrea_session": OAuthCallbackHandler.session_cookie},
448
+ headers=logout_headers,
449
+ timeout=5.0,
450
+ )
451
+ except httpx.HTTPError:
452
+ pass # Best-effort; the session will expire on its own
453
+
454
+ return api_key
455
+ except httpx.HTTPError as e:
456
+ click.echo(f"Error: Failed to create API key: {e}", err=True)
457
+ raise click.Abort() from None